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#
The error values used in MoonBit must have an error type. An error type can be defined in the following forms:
type! E1 Int // error type E1 has one constructor E1 with an Int payload
type! E2 // error type E2 has one constructor E2 with no payload
type! E3 { // error type E3 has three constructors like a normal enum type
A
B(Int, x~ : String)
C(mut x~ : String, Char, y~ : Bool)
}
The return type of a function can include an error type to indicate that the
function might return an error. For example, the following function div
might
return an error of type DivError
:
type! DivError String
fn div(x : Int, y : Int) -> Int!DivError {
if y == 0 {
raise DivError("division by zero")
}
x / y
}
Here, the keyword raise
is used to interrupt the function execution and return
an error.
The Default Error Type#
MoonBit provides a default error type Error
that can be used when the concrete
error type is not important. For convenience, you can annotate the function name
or the return type with the suffix !
to indicate that the Error
type is
used. For example, the following function signatures are equivalent:
fn f() -> Unit! {
...
}
fn g!() -> Unit {
...
}
fn h() -> Unit!Error {
...
}
For anonymous function and matrix function, you can annotate the keyword fn
with the !
suffix to achieve that. For example,
type! IntError Int
fn h(f : (Int) -> Int!, x : Int) -> Unit {
...
}
fn g() -> Unit {
let _ = h(fn! { x => raise IntError(x) }, 0)
let _ = h(fn!(x) { raise IntError(x) }, 0)
}
As shown in the above example, the error types defined by type!
can be used as
value of the type Error
when the error is raised.
Note that only error types or the type Error
can be used as errors. 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 unwrap_or_error[T, E : Error](result : Result[T, E]) -> T!E {
match result {
Ok(x) => x
Err(e) => raise e
}
}
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,
type! E4
type! E5
fn f(e : Error) -> Unit {
match e {
E4 => println("E1")
E5 => println("E2")
_ => println("unknown error")
}
}
Handling Errors#
There are three ways to handle errors:
Append
!
after the function name in a function application to rethrow the error directly in case of an error, for example:
fn div_reraise(x : Int, y : Int) -> Int!DivError {
div!(x, y) // Rethrow the error if `div` raised an error
}
Append
?
after the function name to convert the result into a first-class value of theResult
type, for example:
test {
let res = div?(6, 3)
inspect!(res, content="Ok(2)")
let res = div?(6, 0)
inspect!(
res,
content=
#|Err("division by zero")
,
)
}
Use
try
andcatch
to catch and handle errors, for example:
fn main {
try {
div!(42, 0)
} catch {
DivError(s) => println(s)
} else {
v => println(v)
}
}
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 else
block will be executed instead.
The else
block can be omitted if no action is needed when no error is caught.
For example:
try {
println(div!(42, 0))
} catch {
_ => println("Error")
}
The catch
keyword is optional, and when the body of try
is a simple
expression, the curly braces can be omitted. For example:
let a = try {
div!(42, 0)
} catch {
_ => 0
}
println(a)
The !
and ?
attributes can also be used on method invocation and pipe
operator. For example:
type T Int
type! E Int derive(Show)
fn k(self : T) -> Unit!E {
...
}
fn l() -> Unit!E {
let x = T(42)
k!(x)
x.k!()
x |> k!()
}
However for infix operators such as +
*
that may raise an error,
the original form has to be used, e.g. x.op_add!(y)
, x.op_mul!(y)
.
Additionally, if the return type of a function includes an error type, the
function call must use !
or ?
for error handling, otherwise the compiler
will report an error.
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. For example,
fn f1() -> Unit!E1 {
...
}
fn f2() -> Unit!E2 {
...
}
try {
f1!()
f2!()
} catch {
E1(_) => ...
E2 => ...
_ => ...
}
You can also use catch!
to rethrow the uncaught errors for convenience. This
is useful when you only want to handle a specific error and rethrow others. For
example,
try {
f1!()
f2!()
} catch! {
E1(_) => ...
}
Example: Division by Zero#
We’ll write a small example to demonstrate the basics of MoonBit’s error
handling system. Consider the following div
function which’ll raise an error
on division by zero:
type! DivisionByZeroError String
fn div(x : Int, y : Int) -> Int!DivisionByZeroError {
if y == 0 {
raise DivisionByZeroError("division by zero")
}
x / y
}
In before, we would typically use type
to define a wrapper type which wraps
around some existing foreign type. Here however, we append type
with !
to
define a error type DivisionByZeroError
which wraps around String
.
type! E S
construct a error typeE
fromS
Just like type
, type!
may have a payload like the above DivisionByZeroError
, or may not, or may even have multiple constructors like a normal enum
:
type! ConnectionError {
BrokenPipe(Int,String)
ConnectionReset
ConnectionAbort
ConnectionRefused
}
To utilize DivisionByZeroError
type, we would usually define a function which may raise
error by denoting its return type like T ! E
in the signature, with T
being
the actual return type and E
being the error type. In this case, it’s
Int!DivisionByZeroError
. The error can be thrown using
raise e
where e
is an instance of E
which can be constructed using the
default constructor of S
.
Any instance of an error is a second class object. Meaning it may only appear in the return value. And if it does appear, the function signature has to be adjusted to match with the return type.
The test
block in MoonBit may also be seen as a function, with a return type
of Unit!Error.
Calling an error-able function#
an error-able function is usually called in 2 manners: f!(...)
and f?(...)
.
As-is calling#
f!(...)
calls the function directly. The possible error must be dealt in the
function that calls f
. We can either re-raising it without actually dealing
with the error:
// We have to match the error type of `div2` with `div`
fn div2(x : Int, y : Int) -> Int!DivisionByZeroError {
div!(x,y)
}
or use try...catch
block like in many other languages:
fn div3(x : Int, y : Int) -> Unit {
try {
div!(x, y)
} catch { // `catch` and `except` works the same.
DivisionByZeroError(e) => println("inf: \{e}")
} else {
v => println(v)
}
}
The catch...
clause has similar semantics like pattern matching. We can unwrap
the error to retrieve the underlying String
and print it. Additionally,
there’s an else
clause to handle the value of try...
block.
fn test_try() -> Result[Int, Error] {
// compiler can figure out the type of a local error-able function.
fn f() -> _!_ {
raise Failure("err")
}
try Ok(f!()) { err => Err(err) }
}
Curly braces may be omitted if the body of try is a one-liner (expression). The
catch
keyword can also be omitted as well. In the case where a try
body would raise different errors,
the special catch!
can be used to catch some of the errors, while re-raising other uncaught errors:
type! E1
type! E2
fn f1() -> Unit!E1 { raise E1 }
fn f2() -> Unit!E2 { raise E2 }
fn f() -> Unit! {
try {
f1!()
f2!()
} catch! {
E1 => println("E1")
// E2 gets re-raised.
}
}
Convert to Result#
Extracting values#
A object of type Result
is a first class value in MoonBit. Result
has 2 constructors: Ok(...)
and Err(...)
where the former accept a first class object and the latter accept a error object.
With f?(...)
, the return type T!E
is turned into Result[T,E]
. We may use pattern matching to extract value from it:
let res = div?(10, 0)
match res {
Ok(x) => println(x)
Err(DivisionByZeroError(e)) => println(e)
}
the f?()
is basically a syntactic sugar for
let res = try {
Ok(div!(10, 0))
} catch {
s => Err(s)
}
Note the difference between
T?
andf?(...)
:T
is a type andT?
is equivalent toOption[T]
whereasf?(...)
is a call to an error-able functionf
.
Besides pattern matching, Result
provides some useful methods to deal with possible error:
let res1: Result[Int, String] = Err("error")
let value = res1.or(0) // 0
let res2: Result[Int, String] = Ok(42)
let value = res2.unwrap() // 42
or
returns the value if the result isOk
or a default value if it isErr
unwrap
panics if the result isErr
and return the value if it isOk
Mapping values#
let res1: Result[Int, String] = Ok(42)
let new_result = res1.map(fn(x) { x + 1 }) // Ok(43)
let res2: Result[Int, String] = Err("error")
let new_result = res2.map_err(fn(x) { x + "!" }) // Err("error!")
map
applies a function to the value within, except it doesn’t nothing if result isErr
.map_error
does the opposite.
Unlike some languages, MoonBit treats error-able and nullable value differently. Although one might treat them analogously, as an Err
result contains no value, only the error, which is like null
. MoonBit knows that.
to_option
converts aResult
toOption
.
let res1: Result[Int, String] = Ok(42)
let option = res1.to_option() // Some(42)
let res2: Result[Int, String] = Err("error")
let option1 = res2.to_option() // None
Built-in error type and functions#
In MoonBit, Error
is a generalized error type:
// These signatures are equivalent. They all raise Error.
fn f() -> Unit! { .. }
fn f!() -> Unit { .. }
fn f() -> Unit!Error { .. }
fn test_error() -> Result[Int, Error] {
fn f() -> _!_ {
raise DivisionByZeroError("err")
}
try {
Ok(f!())
} catch {
err => Err(err)
}
}
Although the constructor Err
expects a type of Error
, we may
still pass an error of type DivisionByZeroError
to it.
But Error
can’t be constructed directly. It’s meant to be passed around, not used directly:
type! ArithmeticError
fn what_error_is_this(e : Error) -> Unit {
match e {
DivisionByZeroError(_) => println("DivisionByZeroError")
ArithmeticError => println("ArithmeticError")
... => println("...")
_ => println("Error")
}
}
Error
is typically used where concrete error type is not needed,
or simply act as a catch-all for all kinds of sub-errors.
As Error
includes multiple error types, partial matching is not allowed here. We have to do exhaustive matching by providing a catch-all/wildcard case _
.
We usually use the builtin Failure
error type for a generalized error, and by
generalized we mean using it for trivial errors that doesn’t need a new error type.
fn div_trivial(x : Int, y : Int) -> Int!Failure {
if y == 0 {
raise Failure("division by zero")
}
x / y
}
Besides using the constructor directly, the function fail!
provides a
shorthand to construct a Failure
. And if we take a look at the source code:
pub fn fail[T](msg : String, ~loc : SourceLoc = _) -> T!Failure {
raise Failure("FAILED: \{loc} \{msg}")
}
We can see that fail
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
.
Other functions used to break control flow are abort
and panic
. They are equivalent. An panic
at any place will manually crash the program at that place, and prints out stack trace.