Error handling
Overview
There are three primary ways to represent and handle errors values in Motoko:
Option values with a non-informative
null
value that indicates some error.Result
variants with a descriptive#err value
providing more information about the error.Error
values that, in an asynchronous context, can be thrown and caught similar to exceptions and contain a numeric code and message.
Example
Consider building an API for a to-do application that wants to expose a function allowing users to mark one of their tasks’s as "Done". This simple example will accept a TodoId
object and return an Int
that represents how many seconds the to-do has been open. This example assumes that it is running in an actor, which returns an async value:
func markDone(id : TodoId) : async Int
The full application example can be found below:
import Int "mo:base/Int";
import Hash "mo:base/Hash";
import Map "mo:base/HashMap";
import Time "mo:base/Time";
import Result "mo:base/Result";
import Error "mo:base/Error";
type Time = Int;
type Seconds = Int;
func secondsBetween(start : Time, end : Time) : Seconds =
(end - start) / 1_000_000_000;
public type TodoId = Nat;
type Todo = { #todo : { text : Text; opened : Time }; #done : Time };
type TodoMap = Map.HashMap<TodoId, Todo>;
var idGen : TodoId = 0;
let todos : TodoMap = Map.HashMap(32, Int.equal, Hash.hash);
private func nextId() : TodoId {
let id = idGen;
idGen += 1;
id
};
/// Creates a new todo and returns its id
public shared func newTodo(txt : Text) : async TodoId {
let id = nextId();
let now = Time.now();
todos.put(id, #todo({ text = txt; opened = now }));
id
};
In this example, there are conditions under which marking a to-do as "Done" fails:
The
id
could reference a non-existing to-do.The to-do might already be marked as done.
Let's look at the different ways to communicate these errors in Motoko and slowly improve the example API.
Option/result
Using Option
or Result
is the preferred way of signaling errors in Motoko. They work in both synchronous and asynchronous contexts and make your APIs safer to use by encouraging clients to consider the error cases as well as the success cases. Exceptions should only be used to signal unexpected error states.
Error reporting with Option
types
A function that wants to return a value of type A
or signal an error can return a value of option type ?A
and use the null
value to designate the error. In the above example this means the markDone
function returns an async ?Seconds
:
Definition:
public shared func markDoneOption(id : TodoId) : async ?Seconds {
switch (todos.get(id)) {
case (?(#todo(todo))) {
let now = Time.now();
todos.put(id, #done(now));
?(secondsBetween(todo.opened, now))
};
case _ { null };
}
};
Callsite:
public shared func doneTodo2(id : Todo.TodoId) : async Text {
switch (await Todo.markDoneOption(id)) {
case null {
"Something went wrong."
};
case (?seconds) {
"Congrats! That took " # Int.toText(seconds) # " seconds."
};
};
};
The main drawback of this approach is that it conflates all possible errors with a single, non-informative null
value. The callsite might be interested in why marking a Todo
as done has failed, but that information is lost by then, which means we can only tell the user that "Something went wrong."
.
Returning option values to signal errors should only be used if there just one possible reason for the failure and that reason can be easily determined at the callsite. One example of a good use case for this is a HashMap lookup failing.
Error reporting with Result
types
While options are a built-in type, the Result
is defined as a variant type like so:
type Result<Ok, Err> = { #ok : Ok; #err : Err }
Because of the second type parameter, Err
, the Result
type lets you select the type used to describe errors. Define a TodoError
type that the markDone
function will use to signal errors:
public type TodoError = { #notFound; #alreadyDone : Time };
The original example is now revised as:
Definition:
public shared func markDoneResult(id : TodoId) : async Result.Result<Seconds, TodoError> {
switch (todos.get(id)) {
case (?(#todo(todo))) {
let now = Time.now();
todos.put(id, #done(now));
#ok(secondsBetween(todo.opened, now))
};
case (?(#done(time))) {
#err(#alreadyDone(time))
};
case null {
#err(#notFound)
};
}
};
Callsite:
public shared func doneTodo3(id : Todo.TodoId) : async Text {
switch (await Todo.markDoneResult(id)) {
case (#err(#notFound)) {
"There is no Todo with that ID."
};
case (#err(#alreadyDone(at))) {
let doneAgo = secondsBetween(at, Time.now());
"You've already completed this todo " # Int.toText(doneAgo) # " seconds ago."
};
case (#ok(seconds)) {
"Congrats! That took " # Int.toText(seconds) # " seconds."
};
};
};
Pattern matching
The first and most common way of working with Option
and Result
is to use pattern matching. If you have a value of type ?Text
, you can use the switch
keyword to access the potential Text
contents:
func greetOptional(optionalName : ?Text) : Text {
switch (optionalName) {
case (null) { "No name to be found." };
case (?name) { "Hello, " # name # "!" };
}
};
assert(greetOptional(?"Dominic") == "Hello, Dominic!");
assert(greetOptional(null) == "No name to be found");
Motoko does not let you access the optional value without also considering the case that it is missing.
In the case of a Result
, you can also use pattern matching with the difference that you also get an informative value, not just null
, in the #err
case:
func greetResult(resultName : Result<Text, Text>) : Text {
switch (resultName) {
case (#err(error)) { "No name: " # error };
case (#ok(name)) { "Hello, " # name };
}
};
assert(greetResult(#ok("Dominic")) == "Hello, Dominic!");
assert(greetResult(#err("404 Not Found")) == "No name: 404 Not Found");
Higher-order functions
Pattern matching can become tedious and verbose, especially when dealing with multiple optional values. The base library exposes a collection of higher-order functions from the Option
and Result
modules to improve the ergonomics of error handling.
Sometimes you’ll want to move between Option
and Result
. A Hashmap lookup returns null
on failure, but maybe the caller has more context and can turn that lookup failure into a meaningful Result
. Other times you don’t need the additional information a Result
provides and just want to convert all #err
cases into null
. For these situations base provides the fromOption
and toOption
functions in the Result
module.
Asynchronous errors
The last way of dealing with errors in Motoko is to use asynchronous Error
handling, a restricted form of the exception handling familiar from other languages. Motoko error values can only be thrown and caught in asynchronous contexts, typically the body of a shared
function or async
expression. Non-shared
functions cannot employ structured error handling. This means you can exit a shared function by throw
ing an Error
value and try
some code calling a shared function on another actor. In this workflow, you can catch
the failure as a result of type Error
, but you can’t use these error handling constructs outside of an asynchronous context.
Asynchronous Error
s should generally only be used to signal unexpected failures that you cannot recover from and that you don’t expect many consumers of your API to handle. If a failure should be handled by your caller, you should make it explicit in your signature by returning a Result
instead. For completeness, here is the markDone
example with exceptions:
Definition:
public shared func markDoneException(id : TodoId) : async Seconds {
switch (todos.get(id)) {
case (?(#todo(todo))) {
let now = Time.now();
todos.put(id, #done(now));
secondsBetween(todo.opened, now)
};
case (?(#done(time))) {
throw Error.reject("Already done")
};
case null {
throw Error.reject("Not Found")
};
}
};
Callsite:
public shared func doneTodo4(id : Todo.TodoId) : async Text {
try {
let seconds = await Todo.markDoneException(id);
"Congrats! That took " # Int.toText(seconds) # " seconds.";
} catch (e) {
"Something went wrong.";
}
};
How not to handle errors
A generally poor way of reporting errors is through the use of a sentinel value. For example, for your markDone
function, you might decide to use the value -1
to signal that something failed. The callsite then has to check the return value against this special value and report the error. It's easy to not check for that error condition and continue to work with that value in the code. This can lead to delayed or even missing error detection and is strongly discouraged.
Definition:
public shared func markDoneBad(id : TodoId) : async Seconds {
switch (todos.get(id)) {
case (?(#todo(todo))) {
let now = Time.now();
todos.put(id, #done(now));
secondsBetween(todo.opened, now)
};
case _ { -1 };
}
};
Callsite:
public shared func doneTodo1(id : Todo.TodoId) : async Text {
let seconds = await Todo.markDoneBad(id);
if (seconds != -1) {
"Congrats! That took " # Int.toText(seconds) # " seconds.";
} else {
"Something went wrong.";
};
};