Errors

Even the best-intentioned programs and users occasionally go awry and a language must give you tools to handle them. Magpie has a few tricks up its sleeve for working with errors and exceptional conditions.

Returning Errors

For errors that are common or important enough that you want to ensure programmers handle them, you can simply return an error object. Magpie defines an Error class that is the base class for objects representing errors. Let's say we're writing a method that converts a "yes" or "no" string to a boolean. We can implement that like this:

def parseYesNo(value is String)
    match value
        case "yes" then true
        case "no"  then false
    end
end

What happens if the passed in value is neither "yes" or "no"? A simple solution is to return an error.

def parseYesNo(value is String)
    match value
        case "yes" then true
        case "no"  then false
        else ParseError new("String must be 'yes' or 'no'.")
    end
end

This pushes the problem onto the caller. They can no longer assume parseYesNo will always return a Bool, since it may now also return a ParseError. Pattern-matching gives them a straightforward way to distinguish those cases.

var response = getTextFromUser()
match parseYesNo(response)
    case b is Bool       then "Got good answer"
    case e is ParseError then "Bad input"
end

Throwing Errors

Some errors occur very rarely, such as a stack overflow or out of memory. Other errors indicate bugs in the code that should be fixed instead of handling the error at runtime. For those cases, it's a chore to make the user check and manually handle an error return that they never expect to see.

For those cases, Magpie also supports throwing errors. A throw expression will cause the currently executing code to immediately stop and begin unwinding the callstack. When you throw, you include an error object that describes the problem. By convention, these are subclasses of Error.

Here is a "safe" division function that does not allow dividing by zero. Since attempting to divide by zero indicates a programmer error, it throws instead of returning the error.

def safeDivide(numerator is Int, denominator is Int)
    if denominator == 0 then throw DivideByZeroError new()
    numerator / denominator
end

Catching Errors

Unlike a returned error, a thrown error will not be given back to the calling code. Instead, Magpie will continue to unwind the callstack, causing each successive method to immediately return until the error is caught.

Errors are caught using a catch clause, which is catch followed by a pattern, followed by then, and finally the expression or block to execute when an error is caught.

def canDivide(numerator is Int, denominator is Int)
    safeDivide(numerator, denominator)
    true // If we got here, no error was thrown.
catch err is DivideByZeroError then
    false // If we got here, an error occurred.
end

You can see here that unlike the exception-handling syntax in most languages, Magpie does not have an explicit try syntax. Instead, any block is implicitly a "try" block and may have catch clauses. In canDivide, the block is the method body itself, but other blocks may have catch clauses. For example:

if someCondition then
    doSomethingUnsafe()
catch err is Error then
    // Failed.
else
    doSomethingElse()
catch err is Error then
    // Also failed.
end

(There is one exception to this rule. A block that defines a catch clause's body may not have its own catch clauses.)

A single block may have more than one catch clause. When an error is thrown from the block, each catch clause's pattern is matched against the error in the order that they appear. The first catch clause whose pattern matches catches the error. The body of the catch clause is evaluated and that becomes the value returned by the block.

If no catch clause matches the error, the error continues to propagate.

Errors and "Exceptions"

Magpie's error objects very similar to exceptions in most languages. It uses the term "error" for them because its valid to use errors outside of throw and catch: you can return error objects and pass them around. That's considered poor form in languages that call them "exceptions".

This is handy because the caller and callee may disagree on whether or not an error is important enough to be returned or should be thrown. A caller may catch a thrown error and then return it, or it may throw one that was returned to it. Using the same Error-derived classes for both affords that flexibility.

def returnError()
    doSomethingThatThrows()
catch err is Error then
    err // Return it.
end

def throwError()
    match doSomethingThatReturnsError()
        case err is Error then throw err
        case success then success
    end
end

In other words, an Error object tells you what the error is, but not how it gets passed from the code that generates the error to the code that handles it.