Skip to main content

Basic control flow

In Motoko, code normally executes sequentially, evaluating expressions and declarations in order. However, certain constructs can alter the flow of control, such as exiting a block early, skipping iterations in a loop, returning a value from a function, or invoking another function.

Control flow expressions

ConstructDescription
returnExits a function and returns a value.
ifExecutes a block if the condition is true.
if/elseExecutes different blocks based on a condition.
switchPattern matching for variants, options, results, etc.
let-elseDestructure a pattern and handle the failure case inline.
option blockEvaluates an expression and wraps the result in an option type, allowing scoped handling of null values.
label/breakAllows exiting loops early.
loopIterates indefinitely
loop ... whileIterates until some condition is false
whileIterates while a condition is true.
forIterates over elements in a collection, terminating when no elements remain.

return

A return statement immediately exits a function or async block with a result. Unlike break or continue, which jump to a labeled point within the same function, return does not target a label. Instead, it exits the current function entirely, either returning control to the caller or, in asynchronous contexts, completing a future and resuming the caller that's awaiting the result.

Consider this function that computes the product of an array of integers.

func product(numbers : [Int]) : Int {
var prod : Int = 1;
for (number in numbers.vals()) {
prod *= number;
};
prod; // The implicit result of the block and function
}

This function doesn't require an explicit return. It just returns the result of its body, prod.

However, prod will remain 0 once it becomes 0 so you can save some work by using return to return from the function early, exiting both the loop and the function with result 0.

func product(numbers : [Int]) : Int {
var prod : Int = 1;
for (number in numbers.vals()) {
prod *= number;
if (prod == 0) return 0; // an early return can save work
};
prod; // The implicit result of the block and function
}

This also works with asynchronous functions that produce futures:

func asyncProduct(numbers : [Int]) : async Int {
var prod : Int = 1;
for (number in numbers.vals()) {
prod *= number;
if (prod == 0) return 0; // an early return completes the future
};
prod; // The implicit result of the block and function
}

If the expected return type is () then you can just write return instead of return ().

switch

A switch expression matches a value against multiple cases and executes the block of code associated with the first matching case.

import Nat "mo:base/Nat";

type HttpRequestStatus = {
#ok: Nat;
#err: Nat;
};

func checkStatus(r : HttpRequestStatus) : Text {
switch (r) {
case (#ok successCode) { "Success: " # Nat.toText(successCode) };
case (#err errorCode ) { "Failure: " # Nat.toText(errorCode) };
};
};

let-else

The let-else construct allows conditional binding of the variables in a pattern, by attempting to match a value to the pattern. The else clause handles the case when the pattern is not a match. It is useful when working with Result<T,E> and optional values (?T), enabling concise error handling or early exits when the value is null.

Since the code following the let cannot execute without its matching bindings, the else clause must have type None, typically by diverting control using return, throw, break or continue.

import Nat "mo:base/Nat";

type HttpRequestStatus = {
#ok: Nat;
#err: Nat;
};

func checkStatus(r : HttpRequestStatus) : Text {
let #ok status = r else return "The request failed!";
Nat.toText(status)
};

Unlike a switch, let-else discards any additional error information from non-matching cases, making it less suitable when detailed error handling is needed. The (#err e) case is dropped entirely; e cannot be inspected or logged.

Option block

These blocks represented as do ? {...} allow safe unwrapping of optional values using the postfix operator !, which short-circuits and exits the block with null if any value is null, simplifying code that handles multiple options. The result of the inner block, if any, is returned in an option.

A simple example uses an option block to concisely add optional number, return null when either is null.

 // Returns the sum of optional values `n` and `m` or `null`, if either is `null`
func addOpt(n : ?Nat, m : ?Nat) : ?Nat {
do ? {
n! + m!
}
};
let o1 = addOpt(?5, ?2); // ?7
let o2 = addOpt(null, ?2); // null
let o3 = addOpt(?5, null); // null
let o4 = addOpt(null, null); // null

Instead of having to switch on the options n and m in a verbose manner the use of the postfix operator ! makes it easy to unwrap their values but exit the block with null when either is null.

A more interesting example of option blocks can be found at the end of the section on switch.

label and break

A label assigns a name with an optional type to a block of code that executes like any other block. The type on the label should indicate the type of the block and defaults to () when omitted.

When a labeled block runs, it evaluates the block to produce a result. Labels don’t change how the block executes but enable early exits from the block using a break to that label. If the type is not () those breaks must have an argument, to use as the result of the labelled expression.

Just as return exits a function early with a result, break exits its label early with a result. Indeed, you can think of return as a break from the enclosing function.

func product(numbers : [Int]) : Int {
var prod : Int = 1;
label l for (number in numbers.vals()) {
prod *= number;
if (prod == 0) break l;
};
prod; // The implicit result of the block and function
}

If the block produces a non-() result, as in this minor refactoring, the break should include a value:

func product(numbers : [Int]) : Int {
label result : Int {
var prod : Int = 1;
for (number in numbers.vals()) {
prod *= number;
if (prod == 0) break result 0;
};
prod
}
}

Labels provide fine control over execution, allowing early exits and helping to structure complex logic.

loop

A loop expression repeatedly executes a block of code (forever).

import Debug "mo:base/Debug";
import Nat "mo:base/Nat";

var i = 0;
loop {
Debug.print(Nat.toText(i));
i += 1;
}

loop-while

A loop-while expression repeatedly executes a block of code (at least once) until the while condition evaluates to false.

import Debug "mo:base/Debug";
import Nat "mo:base/Nat";

var i = 0;
loop {
Debug.print(Nat.toText(i));
i += 1;
} while (i < 5)

while

A while loop repeatedly executes a block of code as long as a specified condition evaluates to true. If the condition is initially false, the block is never executed.

import Debug "mo:base/Debug";
import Nat "mo:base/Nat";

var i = 0;
while (i < 5) {
Debug.print(Nat.toText(i));
i += 1;
}

for

A for loop iterates over the elements of an iterator, and object of type { next: () -> ?T }, executing a block of code for each element.

import Debug "mo:base/Debug";
import Nat "mo:base/Nat";

let numbers = [0, 1, 2, 3, 4];
for (num in numbers.vals()) {
Debug.print(Nat.toText(num));
}

It will run forever if the iterator's next method never returns null.

continue

A continue expression skips the remainder of the current iteration in a loop and immediately proceeds to the next iteration. Like break, continue must reference a label and only works within a labeled while, for or loop or loop-while expression.

For example, computing the product we can skip a multiplication when the number is 1.

func product(numbers : [Int]) : Int {
var prod : Int = 1;
label l for (number in numbers.vals()) {
if (number == 1) continue l;
prod *= number;
};
prod;
}

Loop exits

You can alway exit a labelled loop, loop-while, while or for loop or using break and any loop in a function using return or (in an asynchronous function) throw.

Function calls

A function call executes a function by passing arguments and receiving a result. In Motoko, function calls can be synchronous (executing immediately within the same canister) or asynchronous (message passing between canisters). Asynchronous calls use async/await and are essential for inter-canister communication.

import Nat "mo:base/Nat";

persistent actor {

func product(numbers : [Int]) : Int {
var prod : Int = 1;
for (num in numbers.values()) {
prod += num;
if (prod == 0) return 0; // an early return can save work
};
prod;
};

public func asyncProduct(numbers : [Int]) : async Int {
return product(numbers); // function call
};

}

Execution begins in asyncProduct(), where the local function product() is invoked, transferring control to its logic. Inside product(), the numbers are processed one by one. If a zero is encountered, a return statement immediately exits the call to product() and returns 0. Control then flows back to asyncProduct(), which just returns the result, completing the asynchronous call.

Function calls temporarily interrupt the normal sequential flow by shifting execution to a separate block of logic. Once the called function completes, control resumes at the point where the call was made, continuing with its result.