Skip to main content

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 throwing 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 Errors 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.";
};
};