Skip to main content

4.6 Motoko level 4

Overview

In this final Motoko module of the developer journey series, you'll cover the following Motoko concepts and components:

  • Mutable state.

  • Local objects and classes.

  • Message inspection.

  • Errors and options.

Mutable state

Every actor in Motoko uses an internal, mutable state. This internal state is private, and cannot be directly shared with other actors. This is a core Motoko design feature, where mutable data is always kept private to the actor that has allocated it, and it is not shared remotely.

Mutable states are not shareable, since sharing them would mean moving an object's code among other actors and executing it remotely, which poses a security risk, or alternatively sharing the state with a bit of remote logic, which poses another risk.

The term mutable refers to something that can be changed or altered, whereas the term immutable refers to something that cannot be changed or altered, and has a permanent definition.

To understand mutable state, let's take a look at an example where an actor's private state is altered using mutation operations.

Immutable variables versus mutable variables

Immutable variables use the let syntax when being declared, such as:

let textImmutable  : Text = "Developer Journey";
let numImmutable : Nat = 50;

Mutable variables use the var syntax when being declared, such as:

var pairMutable : (Text, Nat) = (textImmutable, numImmutable);
var textMutable : Text = textImmutable;

To update the values of the mutable variables, you can use the following syntax with the assignment operation :=, which works for all types:

textMutable := textMutable # "xyz";
pairMutable := (textMutable, pairMutable.1);
pairMutable

In this syntax, you can update each mutable variable using an update rule for their current values. An actor processes calls by performing updates on its internal mutable variables using the same syntax as above.

Reading data from mutable memory

When data is read from the actor's state, each mutable and immutable variable will look alike, however they will behave differently. For example, consider the following variable declarations:

let x : Nat = 0

and:

var x : Nat = 0

The only difference between these two variable declarations is the use of let for the immutable variable, and var for the mutable variable. However, these variable contain much different meanings, especially in the context of larger projects and programs.

In the immutable variable definition using let, each occurrence of the variable equals 0. Replacing each occurrence with the value 0 will not change the program.

In the mutable variable definition using var, each occurrence of the variable is determined by the mutable, dynamic state of the actor. Each occurrence must read and produce the current value of the memory call for variable x.

let bound variables are more fundamental to a program. To demonstrate why, consider the following var bound variable that uses a single element, a mutable array, that is bound to itself using a let bound variable:

var x : Nat       = 0 ;
let y : [var Nat] = [var 0] ;

In this example, instead of declaring x as a mutable variable that initially holds the value 0, the variable y is used, which is an immutable variable that defines a mutable array that holds a single entry of 0.

It is important to note that the read/write syntax for mutable arrays is not as readable as var bound variables; therefore, reading and writing to variable x will be easier than variable y.

The Motoko standard library provides an Array library that can be used for both mutable and immutable arrays. This library can be imported with the statement:

import Array "mo:base/Array";

Immutable arrays

Before going deeper into mutable arrays, first let's cover immutable arrays, which share the same syntax but do not permit updates.

For example, consider the following immutable array:

let a : [Nat] = [1, 2, 3] ;

Then, to read from this array you can use the traditional bracket syntax of [ and ], to read the desired index:

let x : Nat = a[2] + a[0] ;

Mutable arrays

Since by design, remote actors in Motoko do not share their mutable state, the Motoko type system defines a firm distinction between mutable and immutable arrays. This distinction impacts the language's types, subtypes, and the language's abstractions for asynchronous communication.

Mutable arrays cannot be used in place of immutable onces, since the Motoko definition of subtyping for arrays distinguishes cases for the purposes of type soundness. In regards to actor communication, immutable arrays are safe to send and share, while mutable arrays can only be shared in messages and have non-sharable types.

To declare a mutable array, the [var _] syntax is used. Note that this uses the var keyword for both the expression and types:

let a : [var Nat] = [var 1, 2, 3] ;

This array holds three numbers with type Nat. These three values can be updated, but the number of entries cannot.

To declare a mutable array with a dynamic size, such that the number of entries within the array can be updated, the following Array_init syntax can be used:

var size : Nat = 52 ;
let x : [var Nat] = Array.init<Nat>(size, 5);

Using this syntax, this mutable array will have size number of entries, each with an initial value of 5. Then, to update this mutable array, you can use the syntax:

x[2] := 42;

Using this syntax, each entry can be updated via an assignment to that individual entry. In this example, the index entry of 2 gets updated from equalling 52 to equal 42.

There are two important notes about subtyping with mutable values:

  • Subtyping does not permit mutable values to be used as immutable values.
  • Subtyping does not permit a mutable array of type [var Nat] to be used in places that expect an immutable array of type [Nat].

This is because mutable arrays require different rules for subtyping; in particular, mutable arrays have less flexible subtyping definitions. Additionally, Motoko does not allow mutable arrays to be shared across asynchronous communication, since the mutable state is never shared.

Local objects and classes

Motoko objects encapsulate the canister's local state using var bound variables, packaging the state with any public methods that are used to interact with or update it. As this tutorial mentioned earlier, any objects that include a mutable state are not shareable. To provide a workflow to overcome this limitation, an actor's objects are sharable and always execute remotely. Actor objects and classes communicate exclusively with Motoko data.

In comparison, local objects and classes can pass any Motoko data to another object's methods, including other objects. Local objects and classes are essentially the non-shareable counterparts to actor objects and classes.

Classes are defined by the keyword class, followed by the name for the constructor and the type being defined, optional type arguments, an argument list, and an optional type annotation for constructed objects.

The previous section introduced mutable state declarations of private mutable state using var bound variables and mutable arrays. In this section, you'll use mutable state to implement simple objects.

Object classes versus actor classes

First, let's clearly define the difference between object and actor classes:

  • Object classes: Object classes refer to blueprint used to define a series of related objects with customizable states. Motoko uses a class definition which simplifies building objects of the same implementation and type.

  • Actor classes: When an object class exposes a service, the corresponding Motoko construct is an actor class, which follows a similar design.

For example, let's look at the following object declaration of the object counter:

object counter {
var count = 0;
public func inc() { count += 1 };
public func read() : Nat { count };
public func bump() : Nat {
inc();
read()
};
};

In this declaration, a single object, called counter is defined. In this object there are three public functions, inc, read, and bump, which use the keyword public to declare each as a sharable function. This object definition also include a single private mutable variable called count, defined by the var declaration.

By default, all declarations in an object block are private, such as the variable count is in this example.

Object types

In the previous object declaration, counter has the following object type, which is expressed using a list of field type pairs:

{
inc : () -> () ;
read : () -> Nat ;
bump : () -> Nat ;
}

Each field type includes an identifier, a colon :, and a type for the field's content. Since in this example each field is a function, each includes an arrow -> to display the type form. The type for the variable count is not included in the object's type, since it's presence and information are inaccessible from outside the object.

The inability to access the variable count provides an important benefit: the object has a normal general type with fewer fields, and in return is interchangeable with objects that implement the same counter object type differently, without including a field such as count.

For example, the following variation of the counter declaration above uses the same object type, but uses a different implementation of the count variable using an ordinary Nat value. This object type is interchangeable with the previous example:

import Nat8 "mo:base/Nat8";
object byteCounter {
var count : Nat8 = 0;
public func inc() { count += 1 };
public func read() : Nat { Nat8.toNat(count) };
public func bump() : Nat { inc(); read() };
};

Object subtyping

Next, let's take a look at object subtyping. In Motoko, object subtyping refers to objects that may have various types. Object types with less fields are typically considered subtypes. Object subtypes in Motoko use structural subtyping, where the equality of two types is based on their structure. Two classes with different names but equivalent definitions produce type-compatible objects.

To demonstrate an example of subtyping, let's use a simple counter with a more general type that uses fewer public functions:

object bumpCounter {
var c = 0;
public func bump() : Nat {
c += 1;
c
};
};

The object type for the object bumpCounter exposes only one operation, bump:

{
bump : () -> Nat ;
}

This example exposes the most common operation and only permits a certain behavior. In another part of a program, you may want to implement another version with more generality, such as our example from earlier:

{
inc : () -> () ;
read : () -> Nat ;
bump : () -> Nat ;
}

Lastly, you may implement a type with additional operations, such as a write operation:

{
inc : () -> () ;
read : () -> Nat ;
bump : () -> Nat ;
write : Nat -> () ;
}

If a function expects to receive an object using the first type ({ bump: () → Nat }), any of these versions of the type will work since they are equal to, or a subtype of, the most general version. If you want to use a function that expects to receive an object that uses an additional operation not included in the other types, such as the write operation, then only that type will work.

Object classes

Now let's move onto object classes. An object class is a package of entities that share a common name. For example, let's define a class example for counters that start at zero:

class Counter() {
var c = 0;
public func inc() : Nat {
c += 1;
return c;
}
};

Using this definition, you can construct new counters each with their own unique state that start at zero, such as:

let c1 = Counter();
let c2 = Counter();

Then, you can use each counter, c1 and c2, independently of one another:

let x = c1.inc();
let y = c2.inc();
(x, y)

In comparison, the same results can be achieved by using a constructor function to return an object:

func Counter() : { inc : () -> Nat } =
object {
var c = 0;
public func inc() : Nat { c += 1; c }
};

Note that the return type of this function is { inc : () -> Nat }, which could be named to use in further type declarations, such as type Counter = { inc : () -> Nat };.

Data arguments

In your object class, you initialized counter functions to start with a value of zero. If you want to initialize the counter with a value other than zero, you can pass a data argument into the constructor function:

class Counter(init : Nat) {
var c = init;
public func inc() : Nat { c += 1; c };
};

Notice that now the value of c is set to init rather than set to 0, to reflect that now the counter value will accept a data argument as an initial value.

Message inspection

When a canister receives an ingress message submitted through HTTP, the canister can inspect and either accept or decline the message before executing it. For example, when a canister receives an update call, it uses the canister method canister_inspect_method to determine if the message will be accepted. If a canister does not contain a Wasm module, the message will be rejected automatically. If the canister is not empty, but it does not implement the canister_inspect_message method, then the ingress method will be accepted. canister_inspect_method is not used for HTTP query calls, inter-canister calls, for calls made to the management canister.

Message inspection is designed to mitigate some denial of service attacks, since it drains canister of their cycles for placing unsolicited calls that are rejected.

Actors can elect to inspect messages and make a decision on whether to execute them or not by declaring a system function called inspect. Using the message's attributes, this function produces a true or false value that indicates if the canister decides to accept or decline the message. If a call traps, it will return a false value.

The argument type of the inspect function depends on the actor's interface. The formal argument of the inspect function is a record of fields for the following types:

  • caller: The principal value for the caller of the message.

  • arg: The message's argument content in raw binary.

  • msg: A variant of decoding functions, which contains one variant per shared function and the ID of the actor.

The inspect system function should not be used as a definitive form of access control since it is executed by a single replica and does not go through full consensus and could be maliciously spoofed.

Consider the following inspect function example that denies all ingress messages and ignores all message information:

system func inspect({}) : Bool { false }

In comparison, the following inspect function example declines all messages where the principal value is anonymous:

system func inspect({ caller : Principal }) : Bool {
not (Principal.isAnonymous(caller));
}

Messages can also be declined based on their raw size in bytes (of their arguments prior to any decoding from the Candid binary to the Motoko value), such as:

system func inspect({ arg : Blob }) : Bool {
arg.size() <= 512;
}

Errors and options

In Motoko, there are three primary methods for representing and handling errors. These values are:

  • Option values: Non-informative null values that indicate some error.

  • Result variants: Descriptive #err value that provides more information about the error.

  • Error values: Values that can be thrown and caught in an asynchronous manner, similar to exceptions, that contain a numeric code and a message.

Error handling best practices

Motoko error handling best practices recommend to use Option and Result error handling methods over Exceptions wherever possible. This is because both Option and Result work in synchronous and asynchronous contexts, making APIs safer to use since it encourages clients to consider the error cases along with the success cases. It is recommended to use Exceptions only to signal unexpected error cases.

Error reporting with Option values

If a function wants to return the value of type A, or signal an error can return a value of option type ?A, the null value can designate the error. For example, consider the following markDone function that returns an async value of ?Seconds.

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 };
}
};

Then, you can use the following error 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."
};
};
};

One drawback of this method is that all possible errors return a single, non-informative null value that only tells the user that 'Something went wrong'.

Error reporting with Result variants

As an alternative to using Option types, Result variants can be used to return richer, more defined errors that provide better descriptions of what went wrong. Result variants are a built-in type that is defined as such:

type Result<Ok, Err> = { #ok : Ok; #err : Err }

Since Result includes the Err type parameters, the Result type allows you to select the type used to describe errors. Consider the following example that uses Result variants to signal errors:

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)
};
}
};

Then, you can use the following error 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."
};
};
};

This callsite returns more detailed errors, such as 'There is no todo with that ID', or 'You've already completed this todo', which is much more useful than 'Something went wrong'.

You can learn more about working with Option and Result types here.

Asynchronous errors

Asynchronous errors is a restricted form of exception error handling. Since Motoko error values can only be thrown and caught in asynchronous contexts, non-shared functions cannot employ structured error handling. A shared function can only be exited by throwing an Error value and then trying to call a shared function on another actor. If this shared function returns an Error type, the failure is caught. This error handling cannot be used in regular code outside of an asynchronous context.

Asynchronous error handling generally should only be used for catching and signally unexpected, irrecoverable failures. If a failure can be handled by the caller, it should return a Result instead. The following example demonstrates a form of asynchronous error handling:

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")
};
}
};

Then, you can use the following error 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.";
}
};

Resources

Need help?

Did you get stuck somewhere in this tutorial, or feel like you need additional help understanding some of the concepts? The ICP community has several resources available for developers, like working groups and bootcamps, along with our Discord community, forum, and events such as hackathons. Here are a few to check out:

Next steps

That'll wrap up level 4 of the developer journey! Next, you'll dive into the final level, level 5: