Error handling#

Error handling has always been at core of our language design. In the following we'll be explaining how error handling is done in MoonBit. We assume you have some prior knowledge of MoonBit, if not, please checkout A tour of MoonBit.

Error Types#

In MoonBit, all the error values can be represented by the Error type, a generalized error type.

However, an Error cannot be constructed directly. A concrete error type must be defined, in the following forms:

suberror E1 { E1(Int) } // error type E1 has one constructor E1 with an Int payload

suberror E2 // error type E2 has one constructor E2 with no payload

suberror E3 { // error type E3 has three constructors like a normal enum type
  A
  B(Int, x~ : String)
  C(mut x~ : String, Char, y~ : Bool)
}

Warning

The older suberror A B syntax is deprecated. Use suberror A { A(B) } instead.

The error types can be promoted to the Error type automatically, and pattern matched back:

suberror CustomError { CustomError(UInt) }

test {
  let e : Error = CustomError(42)
  guard e is CustomError(m)
  assert_eq(m, 42)
}

Since the type Error can include multiple error types, pattern matching on the Error type must use the wildcard _ to match all error types. For example,

fn f(e : Error) -> Unit {
  match e {
    E2 => println("E2")
    A => println("A")
    B(i, x~) => println("B(\{i}, \{x})")
    _ => println("unknown error")
  }
}

The Error is meant to be used where no concrete error type is needed, or a catch-all for all kinds of sub-errors is needed.

Failure#

A builtin error type is Failure.

There's a handly fail function, which is merely a constructor with a pre-defined output template for showing both the error and the source location. In practice, fail is always preferred over Failure.

#callsite(autofill(loc))
pub fn[T] fail(msg : String, loc~ : SourceLoc) -> T raise Failure {
  raise Failure("FAILED: \{loc} \{msg}")
}

Throwing Errors#

The keyword raise is used to interrupt the function execution and return an error.

The type declaration of a function can use raise with an Error type to indicate that the function might raise an error during an execution. For example, the following function div might return an error of type DivError:

suberror DivError { DivError(String) } derive(Debug)

fn div(x : Int, y : Int) -> Int raise DivError {
  if y == 0 {
    raise DivError("division by zero")
  }
  x / y
}

The Error can be used when the concrete error type is not important. For convenience, you can omit the error type after the raise to indicate that the Error type is used. For example, the following function signatures are equivalent:

fn f() -> Unit raise {
  ...
}

fn g() -> Unit raise Error {
  let h : () -> Unit raise = fn() raise { fail("fail") }
  ...
}

For functions that are generic in the error type, you can use the Error bound to do that. For example,

// Result::unwrap_or_error
fn[T, E : Error] unwrap_or_error(result : Result[T, E]) -> T raise E {
  match result {
    Ok(x) => x
    Err(e) => raise e
  }
}

For functions that do not raise an error, you can add noraise in the signature. For example:

fn add(a : Int, b : Int) -> Int noraise {
  a + b
}

Error Polymorphism#

It happens when a higher order function accepts another function as parameter. The function as parameter may or may not throw error, which in turn affects the behavior of this function.

A notable example is map of Array:

fn[T] map(array : Array[T], f : (T) -> T raise) -> Array[T] raise {
  let mut res = []
  for x in array {
    res.push(f(x))
  }
  res
}

However, writing so would make the map function constantly having the possibility of throwing errors, which is not the case.

Thus, the error polymorphism is introduced. You may use raise? to signify that an error may or may not be throw.

fn[T] map_with_polymorphism(
  array : Array[T],
  f : (T) -> T raise?
) -> Array[T] raise? {
  let mut res = []
  for x in array {
    res.push(f(x))
  }
  res
}

fn[T] map_without_error(
  array : Array[T],
  f : (T) -> T noraise,
) -> Array[T] noraise {
  map_with_polymorphism(array, f)
}

fn[T] map_with_error(array : Array[T], f : (T) -> T raise) -> Array[T] raise {
  map_with_polymorphism(array, f)
}

The signature of the map_with_polymorphism will be determined by the actual parameter.

Handling Errors#

Applying the function normally will rethrow the error directly in case of an error. For example:

fn div_reraise(x : Int, y : Int) -> Int raise DivError {
  div(x, y) // Rethrow the error if `div` raised an error
}

However, you may want to handle the errors.

Try ... Catch#

You can use try and catch to catch and handle errors, for example:

fn main {
  try div(42, 0) catch {
    DivError(s) => println(s)
  } noraise {
    v => println(v)
  }
}
Output#
division by zero

Here, try is used to call a function that might throw an error, and catch is used to match and handle the caught error. If no error is caught, the catch block will not be executed and the noraise block will be executed instead.

The noraise block can be omitted if no action is needed when no error is caught. For example:

try { println(div(42, 0)) } catch {
  _ => println("Error")
}

When the body of try is a simple expression, the curly braces, and even the try keyword can be omitted. For example:

let a = div(42, 0) catch { _ => 0 }
println(a)

Transforming to Result#

You can also catch the potential error and transform into a first-class value of the Result type, by using try? before an expression that may throw error:

test {
  let res = try? (div(6, 0) * div(6, 3))
  match res {
    Err(DivError(message)) => @test.assert_eq(message, "division by zero")
    Ok(_) => fail("expected division to fail")
  }
}

Panic on Errors#

You can also panic directly when an unexpected error occurs:

fn remainder(a : Int, b : Int) -> Int raise DivError {
  if b == 0 {
    raise DivError("division by zero")
  }
  let div = try! div(a, b)
  a - b * div
}

Error Inference#

Within a try block, several different kinds of errors can be raised. When that happens, the compiler will use the type Error as the common error type. Accordingly, the handler must use the wildcard _ to make sure all errors are caught, and e => raise e to reraise the other errors. For example,

fn f1() -> Unit raise E1 {
  ...
}

fn f2() -> Unit raise E2 {
  ...
}

try {
  f1()
  f2()
} catch {
  E1(_) => ...
  E2 => ...
  e => raise e
}