The Loup Web Book — contents
  1. Chapter 1: Getting Started
  2. Chapter 2: The Web Modes
  3. Chapter 3: Variables and Data Types
  4. Chapter 4: Functions
  5. Chapter 5: Control Flow
  6. Chapter 6: Collections
  7. Chapter 7: Structs
  8. Chapter 8: Enums and Pattern Matching
  9. Chapter 9: Classes and Objects
  10. Chapter 10: Interfaces
  11. Chapter 11: Generics
  12. Chapter 12: Error Handling
  13. Chapter 13: Modules and Packages
  14. Chapter 14: Template Fences — HTML and CSS
  15. Chapter 15: Async and Concurrency
  16. Chapter 16: Reactive UI
  17. Chapter 17: Web Components
  18. Chapter 18: DOM, Storage, Workers, and JavaScript Interop
  19. Chapter 19: SSR, Hydration, and Deployment
  20. Chapter 20: Memory Safety in Web Apps

Chapter 8: Enums and Pattern Matching

What you'll learn. Plain enums, enums whose variants carry data, the built-in Option[T] and Result[T, E], the patterns match accepts (literals, bindings, wildcards, guards, tuples, nested variants, union types), how exhaustiveness checking works, and how to write functions over enums.

Why it matters. A web app is full of values that are one of several shapes: a request that is idle, loading, loaded or failed; an order that is in the cart, placed, shipped or cancelled. A status: string field cannot tell you that you forgot a case. An enum can: when you add a variant, the compiler points at every match that does not handle it yet.

If you have used Rust enums, Swift enums or TypeScript discriminated unions, the idea is the same: a closed set of named variants, each with its own payload, and a match the compiler checks for completeness.

Plain enums

The simplest enum is a closed set of names. Unit-only enums are usually written with one variant per line, each ending in ;:

@mode(web.compiled)

enum Direction {
    North;
    East;
    South;
    West;
}

func arrow(d: Direction): string {
    return match d {
        North => "^",
        East => ">",
        South => "v",
        West => "<",
    };
}

func main(): void {
    let heading = Direction.North;
    println(heading);
    println(arrow(heading));
    println(arrow(Direction.West));
    println(Direction.East == Direction.West);
}

Listing 8-1: A plain enum and an exhaustive match

North
^
<
false

A few things to notice:

  • A variant is written Direction.North where a value is expected. Inside a match arm, the bare name North is enough: the pattern is resolved against the type of the value being matched (Language Reference §1-9.4). The qualified form Direction.North => works too.
  • match is an expression. Its arms are separated by commas, each is pattern => result, and there are no parentheses around the value being matched (unlike if and while).
  • println renders an enum value as its variant name.

Mistake: forgetting a variant

Delete the West arm and the program no longer compiles:

This code does not compile
enum Direction {
    North;
    East;
    South;
    West;
}

func arrow(d: Direction): string {
    return match d {
        North => "^",
        East => ">",
        South => "v",
    };
}

func main(): void {
    println(arrow(Direction.West));
}
main.lpweb:10:12: error[E0001]: Non-exhaustive match: missing variants West

This is the check that makes enums worth using: the message names exactly the variants that are not handled. You can fix it by adding the West arm, or by adding a wildcard arm _ => "?" that matches anything left over. Prefer listing the variants when you can. With a wildcard, a variant added later is silently handled by _; without one, the compiler sends you to every match that needs a decision.

Enums with payloads

A variant can carry data. Variants with payloads are separated by commas, and each payload field has a name and a type:

enum Shape {
    Circle(radius: float),
    Rect(width: float, height: float),
    Point,
}

func area(s: Shape): float {
    return match s {
        Circle(r) => 3.14159 * r * r,
        Rect(w, h) => w * h,
        Point => 0.0,
    };
}

func main(): void {
    let c = Shape.Circle(radius = 2.0);
    let r = Shape.Rect(width = 3.0, height = 4.0);
    println(c);
    println(r);
    println(area(c));
    println(area(r));
    println(area(Shape.Point));
    println(r == Shape.Rect(width = 3.0, height = 4.0));
}

Listing 8-2: Variants with payloads, constructed by name and taken apart by match

Circle(2.0)
Rect(3.0, 4.0)
12.56636
12
0
true

You construct a payload variant with named arguments, like a struct (Shape.Rect(width = 3.0, height = 4.0)); positional arguments also work (Shape.Rect(3.0, 4.0)). In a pattern, the payload is bound by position: Rect(w, h) binds w to the width and h to the height. Use _ for a payload you don't need, as in Rect(w, _).

== on enums is structural: two values are equal when they are the same variant with equal payloads. Like structs, enum values are copied on assignment, and their payloads follow the same pure-value rule as struct fields (Chapter 7).

The two variant separators are not mixed: a unit-only enum uses ;, an enum with payloads uses ,. An enum must declare at least one variant (enum Empty {} is E_ENUM_NO_VARIANTS); the type with no values is spelled impossible.

Option[T]: a value that may be missing

Loup has no null. A value that may be absent has type Option[T], a built-in enum with two variants, Some(T) and None. Both names are available without an import.

@mode(web.compiled)

struct User {
    id: int;
    name: string;
    email: Option[string];
}

func findUser(users: list[User], id: int): Option[User] {
    for (u in users) {
        if (u.id == id) {
            return Some(u);
        }
    }
    return None;
}

func main(): void {
    let users = [
        User(id = 1, name = "Ann", email = Some("ann@example.com")),
        User(id = 2, name = "Bo", email = None),
    ];

    let ann = findUser(users, 1);
    let nobody = findUser(users, 9);

    let greeting = match ann {
        Some(u) => "Hello, " + u.name,
        None => "Who are you?",
    };
    println(greeting);

    println(nobody?.name ?? "(no such user)");
    println(ann.is_some());
    println(nobody.is_none());
    println(ann.map(func(u: User): int => u.name.length()).unwrap_or(0));
    println(repr(nobody));
}

Listing 8-3: Returning and consuming an Option

Hello, Ann
(no such user)
true
true
3
None

The ways to get at the value inside an Option:

  • match handles both cases explicitly. This is the most common form.
  • ?? supplies a fallback: opt ?? default is the value inside Some, or default for None.
  • ?. reads a field or calls a method only when the value is present: nobody?.name is an Option[string], None here.
  • Methods: is_some(), is_none(), unwrap_or(default), map(f), and_then(f), filter(pred), unwrap() and expect(message).

println renders Some(v) as just v and None as an empty string; repr shows the variant, which is what you want in a log line.

Mistake: the spelling of the methods

The Language Reference and older Loup material write the methods in camelCase (isSome, unwrapOr). The 1.25.1 toolchain implements them in snake_case:

This code does not compile
func main(): void {
    let port: Option[int] = Some(8080);
    if (port.isSome()) {
        println("configured");
    }
}
main.lpweb:4:9: error[E0001]: type 'Option[int]' has no method 'isSome'

Write port.is_some(). The same goes for is_none, unwrap_or, and the Result methods below.

Mistake: unwrapping None

unwrap() hands back the value inside Some. On None it throws:

This code throws at run time
struct User {
    id: int;
    name: string;
}

func findUser(users: list[User], id: int): Option[User] {
    for (u in users) {
        if (u.id == id) {
            return Some(u);
        }
    }
    return None;
}

func main(): void {
    let users = [User(id = 1, name = "Ann")];
    println(findUser(users, 9).unwrap().name);
}
Uncaught UnwrapError: called `unwrap()` on a None value
  at main (main.lpweb:18:13)

unwrap() says "I know this is present". When you don't know that, handle the None case:

struct User {
    id: int;
    name: string;
}

func findUser(users: list[User], id: int): Option[User] {
    for (u in users) {
        if (u.id == id) {
            return Some(u);
        }
    }
    return None;
}

func main(): void {
    let users = [User(id = 1, name = "Ann")];
    println(findUser(users, 9)?.name ?? "guest");
}
guest

expect("why it must be there") is unwrap() with your own message, for the cases where absence really is a bug.

Known gap (1.25.1). The Option returned by dict.get(key) works with match, ??, is_some(), unwrap() and unwrap_or(), but map on it fails at run time and repr prints the bare value instead of Some(…). Use match or ?? on a dict.get result.

Result[T, E]: success or a described failure

Result[T, E] is the other built-in enum: Ok(T) for success, Err(E) for failure. Use it when a failure is an expected outcome the caller should decide about, such as invalid input or an insufficient balance.

@mode(web.compiled)

struct Account {
    owner: string;
    balanceCents: int;
}

func withdraw(acct: Account, cents: int): Result[Account, string] {
    if (cents <= 0) {
        return Err("amount must be positive");
    }
    if (cents > acct.balanceCents) {
        return Err(acct.owner + ": insufficient funds");
    }
    return Ok(acct with (balanceCents = acct.balanceCents - cents));
}

func deposit(acct: Account, cents: int): Result[Account, string] {
    if (cents <= 0) {
        return Err("amount must be positive");
    }
    return Ok(acct with (balanceCents = acct.balanceCents + cents));
}

func transfer(source: Account, target: Account, cents: int): Result[(Account, Account), string] {
    let newSource = withdraw(source, cents)?;
    let newTarget = deposit(target, cents)?;
    return Ok((newSource, newTarget));
}

func main(): void {
    let ann = Account(owner = "ann", balanceCents = 5000);
    let bo = Account(owner = "bo", balanceCents = 1000);

    match transfer(ann, bo, 1500) {
        Ok((a, b)) => println(str(a.balanceCents) + " / " + str(b.balanceCents)),
        Err(msg) => println("failed: " + msg),
    }
    match transfer(bo, ann, 9999) {
        Ok((a, b)) => println(str(a.balanceCents) + " / " + str(b.balanceCents)),
        Err(msg) => println("failed: " + msg),
    }

    let r = withdraw(ann, 700);
    println(r.is_ok());
    println(r.map(func(a: Account): int => a.balanceCents).unwrap_or(0));
    println(withdraw(ann, -5).unwrap_err());
}

Listing 8-4: Functions that return Result, and ? to pass an Err up

3500 / 2500
failed: bo: insufficient funds
true
4300
amount must be positive

The postfix ? in transfer is the propagation operator (Language Reference §1-5.7). withdraw(source, cents)? is the Account inside Ok; if the result is an Err, transfer returns that Err at once. ? is allowed only in a function that itself returns a Result.

The Result methods implemented today are is_ok(), is_err(), unwrap(), expect(message), unwrap_or(default), unwrap_err(), map(f), map_err(f) and and_then(f). Chapter 12 compares Result with exceptions and covers when to use each.

The arm pattern Ok((a, b)) takes the tuple apart inside the Ok; nested patterns are covered below.

Mistake: from as a parameter name

A first draft of transfer would naturally say transfer(from: Account, to: Account, …):

This code does not compile
struct Account {
    owner: string;
    balanceCents: int;
}

func transfer(from: Account, to: Account, cents: int): Result[int, string] {
    return Ok(cents);
}

func main(): void {
    println("never runs");
}
main.lpweb:7:15: error[E0001]: `from` is a reserved word (§1-3.4) and cannot name a parameter
  note: rename the parameter — e.g. `from_value`

from is reserved because it starts an import (from core.math import (sqrt);). Rename the parameter, as Listing 8-4 does with source and target.

Mistake: unwrapOr compiles, then fails

On Result, the camelCase spelling is not caught by the compiler. It fails when the call runs:

This code throws at run time
func withdraw(balance: int, amount: int): Result[int, string] {
    if (amount > balance) {
        return Err("insufficient funds");
    }
    return Ok(balance - amount);
}

func main(): void {
    let left = withdraw(100, 250).unwrapOr(100);
    println(left);
}
Uncaught RuntimeError: internal interpreter limitation: unsupported instruction: CallVirtual unwrapOr on user class Result — method not found

The message says the interpreter could not find a method called unwrapOr on Result. The fix is the snake_case name, unwrap_or(100). Because this mistake gets past the compiler, search your code for unwrapOr, isOk and isErr when you port examples from older material.

Mistake: ? on an Option

? works on Result, not on Option:

This code does not compile
func firstEven(xs: list[int]): Option[int] {
    for (x in xs) {
        if (x % 2 == 0) {
            return Some(x);
        }
    }
    return None;
}

func doubledFirstEven(xs: list[int]): Option[int] {
    let n = firstEven(xs)?;
    return Some(n * 2);
}

func main(): void {
    println(doubledFirstEven([1, 4, 5]));
}
main.lpweb:12:13: error[E_PROPAGATE_WRONG_RETURN]: E_PROPAGATE_WRONG_RETURN: the '?' operator can only be used inside a function returning Result (§1-5.7)

For an Option, use map, and_then, ?? or a match. (Inside a function that returns Result, the 1.25.1 compiler does accept ? on an Option and turns a None into Err(None). The Language Reference does not allow this, so don't rely on it.)

func firstEven(xs: list[int]): Option[int] {
    for (x in xs) {
        if (x % 2 == 0) {
            return Some(x);
        }
    }
    return None;
}

func doubledFirstEven(xs: list[int]): Option[int] {
    return firstEven(xs).map(func(n: int): int => n * 2);
}

func main(): void {
    println(repr(doubledFirstEven([1, 4, 5])));
    println(repr(doubledFirstEven([1, 3])));
    println(firstEven([1, 3]) ?? -1);
}
Some(8)
None
-1

match in depth

Literal patterns and wildcards

A literal pattern matches an equal value. _ matches anything and binds nothing:

func command(word: string): string {
    return match word {
        "start" => "starting",
        "stop" => "stopping",
        _ => "unknown command: " + word,
    };
}

func main(): void {
    println(command("stop"));
    println(command("pause"));
}
stopping
unknown command: pause

A match over an int or a string has no fixed set of variants, so it needs a _ arm (or a binding arm) to be exhaustive.

Binding patterns and guards

A plain name in a pattern binds the value. A guard, if (condition) after the pattern, adds a condition the arm must also satisfy:

func sizeLabel(bytes: Option[int]): string {
    return match bytes {
        Some(n) if (n >= 1000000) => i"{$ n // 1000000 $} MB",
        Some(n) if (n >= 1000) => i"{$ n // 1000 $} KB",
        Some(n) => i"{$ n $} bytes",
        None => "size unknown",
    };
}

func main(): void {
    println(sizeLabel(Some(2500000)));
    println(sizeLabel(Some(4096)));
    println(sizeLabel(Some(12)));
    println(sizeLabel(None));
}

Listing 8-5: Guards choose between arms that match the same variant

2 MB
4 KB
12 bytes
size unknown

Arms are tried from top to bottom, and the first arm whose pattern and guard both match wins. For exhaustiveness the compiler treats a guarded arm as one that might not match, which is why Some(n) => without a guard is still needed.

Known gap: a guarded binding next to literal arms

Mapping HTTP status codes to text mixes literal arms with a guarded binding. In 1.25.1 that combination misbehaves:

This code does not do what it should
func statusText(code: int): string {
    return match code {
        200 => "OK",
        404 => "Not Found",
        n if (n >= 500) => i"server error {$ n $}",
        n => i"status {$ n $}",
    };
}

func main(): void {
    println(statusText(503));
    println(statusText(418));
}
server error 
status 418

The guard selects the right arm, but in a match that also has literal arms, the name bound by a guarded arm renders empty in the arm's body. This is an interpreter bug, not something your code did wrong. Until it is fixed, guard on the value you are matching instead of on a new binding:

func statusText(code: int): string {
    return match code {
        200 => "OK",
        404 => "Not Found",
        _ if (code >= 500) => i"server error {$ code $}",
        n => i"status {$ n $}",
    };
}

func main(): void {
    println(statusText(200));
    println(statusText(404));
    println(statusText(503));
    println(statusText(418));
}

Listing 8-6: Literal arms, a guarded wildcard and a binding arm

OK
Not Found
server error 503
status 418

Tuple patterns

Matching a tuple lets one match look at several values at once. Tuple patterns may contain literals, bindings, _ and nested tuples:

func quadrant(p: (int, int)): string {
    return match p {
        (0, 0) => "origin",
        (0, _) => "on the y axis",
        (_, 0) => "on the x axis",
        _ if (p[0] > 0 and p[1] > 0) => "first quadrant",
        _ => "elsewhere",
    };
}

func main(): void {
    println(quadrant((0, 0)));
    println(quadrant((0, 5)));
    println(quadrant((3, 4)));
    println(quadrant((-1, 4)));
}
origin
on the y axis
first quadrant
elsewhere

Enum variant patterns are not allowed inside a tuple pattern in 1.25.1; the worked example at the end of the chapter shows what to do instead.

Nested patterns

A pattern can look inside a variant's payload. Arms are tried in order and an arm matches only if its whole pattern matches, so Ok(Some(n)) and Ok(None) are different cases:

func describe(r: Result[Option[int], string]): string {
    return match r {
        Ok(Some(n)) => i"value {$ n $}",
        Ok(None) => "no value",
        Err(e) => "error: " + e,
    };
}

func main(): void {
    println(describe(Ok(Some(5))));
    println(describe(Ok(None)));
    println(describe(Err("timeout")));
}
value 5
no value
error: timeout

Mistake: one arm for two variants

Rust and TypeScript let one arm cover several cases with |. Loup does not (Language Reference §1-9.4: each arm has exactly one pattern):

This code does not compile
enum Direction {
    North;
    East;
    South;
    West;
}

func axis(d: Direction): string {
    return match d {
        North | South => "vertical",
        East | West => "horizontal",
    };
}

func main(): void {
    println(axis(Direction.West));
}
main.lpweb:11:15: error[E0001]: expected FatArrow, found Pipe
main.lpweb:11:15: error[E0001]: expected expression
main.lpweb:12:14: error[E0001]: expected FatArrow, found Pipe
main.lpweb:12:14: error[E0001]: expected expression

The parser expected the => after the first pattern and found |. Write one arm per variant:

enum Direction {
    North;
    East;
    South;
    West;
}

func axis(d: Direction): string {
    return match d {
        North => "vertical",
        South => "vertical",
        East => "horizontal",
        West => "horizontal",
    };
}

func main(): void {
    println(axis(Direction.West));
}
horizontal

When many variants share a result, a guard over a helper function (_ if (isVertical(d)) => …) also works.

Block arms and match as a statement

An arm's result can be a block. The block's last expression is the arm's value. When you only want side effects, use match as a statement:

enum Shape {
    Circle(radius: float),
    Rect(width: float, height: float),
    Point,
}

func perimeter(s: Shape): float {
    return match s {
        Circle(r) => {
            let pi = 3.14159;
            2.0 * pi * r
        },
        Rect(w, h) => 2.0 * (w + h),
        Point => 0.0,
    };
}

func main(): void {
    println(perimeter(Shape.Circle(radius = 1.0)));
    let shapes = [Shape.Circle(radius = 2.0), Shape.Point, Shape.Rect(width = 1.0, height = 1.0)];
    for (s in shapes) {
        match s {
            Circle(r) => println("circle " + str(r)),
            Rect(w, h) => println("rect " + str(w) + "x" + str(h)),
            Point => println("point"),
        }
    }
}
6.28318
circle 2
point
rect 1x1

Matching union types

A value of a union type such as int | string | bool (Chapter 3) is matched by type. The pattern int(n) matches an int and binds it:

func describe(v: int | string | bool): string {
    return match v {
        int(n) => i"int {$ n $}",
        string(s) => "string " + s,
        bool(b) => i"bool {$ b $}",
    };
}

func label(v: string | int): string {
    if (v is string) {
        return "text: " + v.toUpperCase();
    }
    return "number";
}

func main(): void {
    println(describe(42));
    println(describe("hi"));
    println(describe(true));
    println(label("ok"));
    println(label(3));
}
int 42
string hi
bool true
text: OK
number

The match must cover every member of the union; leaving out bool is Non-exhaustive match: missing types bool. For a single check, is narrows the type inside the if branch, so v.toUpperCase() is allowed there.

Generic enums

An enum can take type parameters, and a variant can refer to the enum itself. That makes trees easy to express:

enum Tree[T] {
    Leaf(value: T),
    Node(left: Tree[T], right: Tree[T]),
}

func countLeaves[T](t: Tree[T]): int {
    return match t {
        Leaf(_) => 1,
        Node(l, r) => countLeaves(l) + countLeaves(r),
    };
}

func sumTree(t: Tree[int]): int {
    return match t {
        Leaf(v) => v,
        Node(l, r) => sumTree(l) + sumTree(r),
    };
}

func main(): void {
    let t = Tree.Node(
        left = Tree.Leaf(value = 1),
        right = Tree.Node(left = Tree.Leaf(value = 2), right = Tree.Leaf(value = 3)),
    );
    println(countLeaves(t));
    println(sumTree(t));
}

Listing 8-7: A recursive generic enum

3
6

Option[T] and Result[T, E] are generic enums of exactly this kind. Chapter 11 covers generics in general.

Functions over enums

In Rust or Swift you would put a typeName() method inside the enum. In Loup, an enum body holds only variants (the grammar in Language Reference §1-19 has no other member):

This code does not compile
enum Json {
    Null,
    Number(value: float),
    Text(value: string),

    func typeName(): string {
        return "?";
    }
}

func main(): void {
    println("never runs");
}
main.lpweb:7:5: error[E0001]: `func` is a reserved word (§1-3.4) and cannot name an enum variant
  note: rename the enum variant — e.g. `func_value`
main.lpweb:7:10: error[E0002]: expected `}`
main.lpweb:7:20: error[E0003]: expected `;`
main.lpweb:9:5: error[E0001]: expected expression
main.lpweb:10:1: error[E0001]: expected expression

The parser read func as the name of a fourth variant, which is why the first message talks about a reserved word. Write the operation as a module-level function that takes the enum:

enum Json {
    Null,
    Bool(value: bool),
    Number(value: float),
    Text(value: string),
    Array(items: list[Json]),
}

func typeName(v: Json): string {
    return match v {
        Null => "null",
        Bool(_) => "bool",
        Number(_) => "number",
        Text(_) => "string",
        Array(_) => "array",
    };
}

func countScalars(v: Json): int {
    return match v {
        Array(items) => {
            var n = 0;
            for (item in items) {
                n = n + countScalars(item);
            }
            n
        },
        _ => 1,
    };
}

func main(): void {
    let doc = Json.Array(items = [
        Json.Number(value = 1.5),
        Json.Text(value = "hi"),
        Json.Array(items = [Json.Bool(value = true), Json.Null]),
    ]);
    println(typeName(doc));
    println(countScalars(doc));
    println(doc);
}

Listing 8-8: A recursive Json enum with functions that match over it

array
4
Array([Number(1.5), Text("hi"), Array([Bool(true), Null])])

Adding a variant to Json makes typeName fail to compile until it handles the new case. countScalars has a _ arm, so it would accept the new variant silently; that is the trade-off from the start of the chapter.

A worked example: an order state machine

An online order moves through states in response to events. Some transitions are allowed (a placed order can be shipped) and some are not (a shipped order cannot be cancelled). Two enums and a function that returns a Result describe the whole thing.

The first attempt matches on the pair (state, event):

This code does not compile
enum OrderState {
    Cart,
    Placed(orderId: string),
}

enum OrderEvent {
    Checkout(orderId: string),
    Cancel(reason: string),
}

func next(state: OrderState, event: OrderEvent): Result[OrderState, string] {
    return match (state, event) {
        (Cart, Checkout(id)) => Ok(OrderState.Placed(orderId = id)),
        _ => Err("not allowed"),
    };
}

func main(): void {
    println("never runs");
}
main.lpweb:14:10: error[E0001]: unsupported pattern inside a tuple pattern (only literals, bindings, `_` and nested tuples are supported)
main.lpweb:14:16: error[E0001]: unsupported pattern inside a tuple pattern (only literals, bindings, `_` and nested tuples are supported)

In 1.25.1 a tuple pattern cannot contain variant patterns. Match on the state first, then on the event inside each arm:

@mode(web.compiled)

enum OrderState {
    Cart,
    Placed(orderId: string),
    Shipped(orderId: string, tracking: string),
    Delivered(orderId: string),
    Cancelled(reason: string),
}

enum OrderEvent {
    Checkout(orderId: string),
    Ship(tracking: string),
    Deliver,
    Cancel(reason: string),
}

func rejected(state: OrderState, event: OrderEvent): Result[OrderState, string] {
    return Err("cannot apply " + str(event) + " to " + str(state));
}

func next(state: OrderState, event: OrderEvent): Result[OrderState, string] {
    return match state {
        Cart => match event {
            Checkout(id) => Ok(OrderState.Placed(orderId = id)),
            Cancel(why) => Ok(OrderState.Cancelled(reason = why)),
            _ => rejected(state, event),
        },
        Placed(id) => match event {
            Ship(tracking) => Ok(OrderState.Shipped(orderId = id, tracking = tracking)),
            Cancel(why) => Ok(OrderState.Cancelled(reason = why)),
            _ => rejected(state, event),
        },
        Shipped(id, _) => match event {
            Deliver => Ok(OrderState.Delivered(orderId = id)),
            _ => rejected(state, event),
        },
        _ => rejected(state, event),
    };
}

func run(events: list[OrderEvent]): Result[OrderState, string] {
    var state = OrderState.Cart;
    for (e in events) {
        state = next(state, e)?;
    }
    return Ok(state);
}

func describe(result: Result[OrderState, string]): string {
    return match result {
        Ok(Delivered(id)) => "order " + id + " delivered",
        Ok(other) => "order is now " + str(other),
        Err(msg) => "rejected: " + msg,
    };
}

func main(): void {
    let happy = [
        OrderEvent.Checkout(orderId = "A-17"),
        OrderEvent.Ship(tracking = "1Z999"),
        OrderEvent.Deliver,
    ];
    let late = [
        OrderEvent.Checkout(orderId = "A-18"),
        OrderEvent.Ship(tracking = "1Z123"),
        OrderEvent.Cancel(reason = "changed mind"),
    ];
    let early = [
        OrderEvent.Checkout(orderId = "A-19"),
        OrderEvent.Cancel(reason = "duplicate"),
    ];
    println(describe(run(happy)));
    println(describe(run(late)));
    println(describe(run(early)));
}

Listing 8-9: An order state machine: enums for states and events, Result for rejected transitions

order A-17 delivered
rejected: cannot apply Cancel("changed mind") to Shipped("A-18", "1Z123")
order is now Cancelled("duplicate")

The pieces from this chapter fit together here:

  • next is a match inside a match. Each state lists the events it accepts; everything else goes to rejected.
  • run applies events in order and uses ? to stop at the first rejected transition.
  • describe uses a nested pattern, Ok(Delivered(id)), before the more general Ok(other).
  • str(event) and str(state) render enum values the same way println does, which gives readable error messages for free.

The same shape (an enum of states, an enum of events, a function from one state to the next) is how you would model a form wizard or a fetch lifecycle in a web UI.

Try it yourself

  1. Add a Returned(orderId: string) state that a Delivered order can move to on a new Return event. Compile before you update next and describe; which match expressions does the compiler point at, and which ones does it not, because of a _ arm?
  2. Model a fetch lifecycle: enum Fetch[T] { Idle, Loading, Loaded(value: T), Failed(message: string) }, and write func label(f: Fetch[int]): string.
  3. Write func depth(v: Json): int for the Json enum in Listing 8-8.

Summary

  • An enum is a closed set of variants. Unit-only enums use ; between variants; enums with payloads use ,.
  • Construct a variant as Shape.Rect(width = 3.0, height = 4.0); take it apart with Rect(w, h) => in a match. Enum == is structural.
  • match must be exhaustive. The compiler names the missing variants or union members; a _ arm covers the rest, at the cost of not being told about new variants.
  • Patterns: literals, bindings, _, guards (if (…)), tuples, nested variants such as Ok(Some(n)), and type patterns such as int(n) for unions. There are no | alternatives in an arm.
  • Option[T] replaces null; use match, ??, ?. and the snake_case methods (is_some, unwrap_or, map, …). unwrap() on None throws UnwrapError.
  • Result[T, E] carries a success or a failure; ? passes an Err up from a function that returns Result, and does not apply to Option.
  • Enum bodies hold only variants; write operations as functions that take the enum.
  • Known gaps in 1.25.1: camelCase Result methods such as unwrapOr compile and then fail at run time; a guarded binding in a match that also has literal arms renders empty; variant patterns cannot appear inside tuple patterns; map and repr misbehave on a dict.get result.