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_message
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_message
is not used for HTTP query calls, inter-canister calls, and for calls made to the management canister. Please make sure to get familiar with the canister development security best practices before using this feature in production, because canister_inspect_message
is not a secure way of implementing e.g. authorization checks.
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'.
Learn more about working with Option
and Result
types.
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:
Developer Discord community, which is a large chatroom for ICP developers to ask questions, get help, or chat with other developers asynchronously via text chat.
Motoko Bootcamp - The DAO Adventure - Discover the Motoko language in this 7 day adventure and learn to build a DAO on the Internet Computer.
Motoko Bootcamp - Discord community - A community for and by Motoko developers to ask for advice, showcase projects and participate in collaborative events.
Weekly developer office hours to ask questions, get clarification, and chat with other developers live via voice chat. This is hosted on our developer Discord group.
Submit your feedback to the ICP Developer feedback board.
Next steps
That'll wrap up level 4 of the developer journey! Next, you'll dive into the final level, level 5: