Skip to content

Match

Select arms in order, produce values, guard with conditions, refine unions, and satisfy exhaustiveness.

Updated View as Markdown

match evaluates a subject once and runs the first arm that accepts it.

enum Status { Pending, Running, Complete }

fn label(status: Status): string {
    return match status {
        Pending => "pending"
        Running => "running"
        Complete => "complete"
    }
}

Selection happens in three stages, in this order: evaluate the subject, test an arm’s structural pattern and create its bindings, then evaluate that arm’s guard. The first arm that clears every applicable stage wins; later arms never run.

Arms

An arm is a pattern, an optional guard, =>, and a body. The body is one expression or a block.

enum Message { Data(int), Flush, Reset }

fn handle(message: Message): int {
    return match message {
        Data(value) => value * 2
        Flush => {
            println("flushing")
            0
        }
        Reset => -1
    }
}

Arms are separated by newlines. A trailing comma is accepted where the grammar allows one, but the book uses line-separated arms throughout.

enum Mode { Fast, Safe }

fn cost(mode: Mode): int {
    return match mode {
        Fast => 1,
        Safe => 5,
    }
}

Pattern bindings are visible in that arm’s guard and body, and nowhere else.

Match as an expression and as a statement

Every arm that completes normally must produce a compatible value.

enum Response { Success(int), Missing, Failure(string) }

fn code(response: Response): int {
    return match response {
        Success(_) => 200
        Missing => 404
        Failure(_) => 500
    }
}

A match whose arms are all void is a statement, which is the idiomatic shape when the arms exist for their effects:

enum Event { Started, Stopped }

fn observe(event: Event): int {
    mut running := 0
    match event {
        Started => running = 1
        Stopped => running = 0
    }
    return running
}

An arm that transfers control diverges and therefore contributes nothing to the result type:

error LoadError { NotFound }

fn load(id: int): int ! LoadError {
    if id < 0 { error NotFound }
    return id
}

fn double_or_exit(id: int): int {
    value := match load(id) {
        Ok(value) => value
        Err(err) => return -1
    }
    return value * 2
}

Guards

An if guard adds a boolean requirement after the structural test.

fn category(value: int): string {
    return match value {
        number if number < 0 => "negative"
        0 => "zero"
        number if number > 1000 => "huge"
        _ => "positive"
    }
}

The pattern binds first, then the guard runs. A false guard resumes matching at the next arm — so effects inside a guard have already happened when selection continues. Keep guards observational and move real work into the body.

Guards do not make a variant exhaustive: arbitrary boolean logic cannot be shown to cover every value, so a guarded arm always needs an unguarded companion.

fn f(value: int?): int {
    return match value {
        Some(n) if n > 0 => n
        None => 0
    }
}

Adding Some(_) => 0 closes that match.

Exhaustiveness

A match over a closed domain must cover every case. Booleans, enums, Option, Result, declared errors, and known anonymous unions are closed.

fn unwrap_or_zero(value: Option[int]): int {
    return match value {
        Some(number) => number
        None => 0
    }
}

A missing case is ATOLL2010:

enum Stage { Queued, Running, Done }

fn label(stage: Stage): string {
    return match stage {
        Queued => "queued"
        Running => "running"
    }
}

That reports non-exhaustive match on Stage — missing: Done. Booleans behave the same way:

fn f(flag: bool): int {
    return match flag {
        true => 1
    }
}

Integers, strings, floats, and lists are open-ended, so they need a wildcard:

fn describe(status: int): string {
    return match status {
        200 => "ok"
        404 => "missing"
        500..=599 => "server error"
        _ => "other"
    }
}

The check covers the top level of the subject’s type. Nested payloads are not verified today, so this compiles even though nothing handles Loaded(None):

enum Response { Loaded(int?), Failed(string) }

fn describe(response: Response): string {
    return match response {
        Loaded(Some(value)) => "value ${value}"
        Failed(message) => message
    }
}

Do not rely on that. Write nested coverage out yourself — either as explicit arms or by collapsing the payload with _:

enum Response { Loaded(int?), Failed(string) }

fn describe(response: Response): string {
    return match response {
        Loaded(Some(value)) => "value ${value}"
        Loaded(None) => "empty"
        Failed(message) => message
    }
}

The same shape written as two nested matches gets the payload checked, because the inner Option then is a top-level subject:

enum Response { Loaded(int?), Failed(string) }

fn describe(response: Response): string {
    return match response {
        Loaded(payload) => match payload {
            Some(value) => "value ${value}"
            None => "empty"
        }
        Failed(message) => message
    }
}

Choosing between a wildcard and a named exhaustive match is a design decision: a wildcard lets a future variant compile without review, while listing every variant turns the new one into a diagnostic at every decision site.

Ordering

Arms are tried top to bottom, so a broad pattern placed early hides the specific arms after it. The compiler does not currently reject the shadowed arm, which makes ordering entirely your responsibility.

enum Stage { Queued, Running, Done }

fn label(stage: Stage): string {
    return match stage {
        Queued => "queued"
        _ => "other"
        Running => "running"
    }
}

That compiles, but Running can never be selected. Put wildcards and group patterns after the cases they are meant to leave visible.

The same rule governs guards: the guarded arm must precede the unguarded one for the same pattern, or the unguarded arm swallows every case.

struct Order { total: int, rush: bool }

fn fee(order: Order): int {
    return match order {
        Order { total, rush: true } if total > 100 => 0
        Order { total: _, rush: true } => 15
        Order { total, rush: false } if total > 100 => 5
        _ => 10
    }
}

Alternatives

| groups patterns that share a body. Bind the same names on every side so the body has one consistent environment.

enum Lookup {
    Loaded(string)
    Cached(string)
    Missing
}

fn text(lookup: Lookup): string {
    return match lookup {
        Loaded(value) | Cached(value) => value
        Missing => "unavailable"
    }
}

Unions

Matching an anonymous union refines the subject to the selected constituent, and the binding has that refined type inside the arm.

fn describe(value: int | string | bool): string {
    return match value {
        n: int => "number ${n + 1}"
        s: string => "text of ${s.len()} chars"
        b: bool => "flag ${b}"
    }
}

When two constituent enums expose a variant with the same name, qualify it:

enum NetworkState { Ready, Down }
enum StorageState { Ready, Full }

fn network(state: NetworkState): int {
    return match state {
        NetworkState.Ready => 1
        NetworkState.Down => 0
    }
}

fn storage(state: StorageState): int {
    return match state {
        StorageState.Ready => 1
        StorageState.Full => 0
    }
}

Error unions use the same machinery and are covered in Error Unions.

Subjectless match

match with no subject checks arms in order. Each arm is still a pattern, so a bare boolean expression is not accepted — write _ if condition.

fn temperature_label(celsius: int): string {
    return match {
        _ if celsius < 0 => "freezing"
        _ if celsius < 20 => "cold"
        _ => "warm"
    }
}
fn temperature_label(celsius: int): string {
    return match {
        celsius < 0 => "freezing"
        _ => "warm"
    }
}

A flat decision table reads well this way. For two branches, an if chain is usually clearer. Matching on the boolean itself is a third option:

fn temperature_label(celsius: int): string {
    return match celsius < 0 {
        true => "freezing"
        false => "warm"
    }
}

Constants and ranges

A visible constant in a pattern matches its folded value; the compiler distinguishes a resolved constant from a new binding.

const HTTP_OK = 200
const HTTP_TEAPOT = 418

fn label(status: int): string {
    return match status {
        HTTP_OK => "ok"
        HTTP_TEAPOT => "teapot"
        400..=499 => "client error"
        500..=599 => "server error"
        _ => "other"
    }
}

Literal and range arms use value equality and range membership. They never convert the subject to another type to make a pattern fit.

Ownership

Matching follows the subject’s ordinary value and reference semantics. Destructuring does not by itself grant mutation — bind through a mutable place or call a method whose receiver contract permits it.

struct Counter { hits: int }

impl Counter {
    fn bump(mut self): void { self.hits += 1 }
}

enum Signal { Hit, Idle }

fn apply(signal: Signal): int {
    mut counter := Counter { hits: 0 }
    match signal {
        Hit => counter.bump()
        Idle => {}
    }
    return counter.hits
}

Match versus catch

Use match when both the success and the error channel need handling. Use catch when the success value should pass through untouched and only the failure needs attention.

error LoadError { NotFound, Corrupt }

fn load(id: int): string ! LoadError {
    if id < 0 { error NotFound }
    return "user-${id}"
}

fn via_match(id: int): string {
    return match load(id) {
        Ok(name) => name
        Err(err) => "unknown"
    }
}

fn via_catch(id: int): string {
    name := load(id) catch {
        NotFound => return "unknown"
        Corrupt => return "damaged"
    }
    return name
}

Putting it together

error QueryError { Unsupported { verb: string } }

enum Node {
    Literal(int)
    Sum(int, int)
    Named { key: string, fallback: int }
    Missing
}

fn evaluate(node: Node, bindings: Map[string, int]): int ! QueryError {
    return match node {
        Literal(value) if value < 0 => error Unsupported { verb: "negative literal" }

        Literal(value) => value

        Sum(left, right) => left + right

        Named { key, fallback } => match bindings.get(key) {
            Some(value) if value > 0 => value
            Some(_) => fallback
            None => fallback
        }

        Missing => 0
    }
}

fn evaluate_all(nodes: []Node, bindings: Map[string, int]): int {
    mut total := 0
    for node in nodes {
        value := match evaluate(node, bindings) {
            Ok(value) => value
            Err(err) => continue
        }
        total += value
    }
    return total
}

The outer match is exhaustive over Node, the guarded Literal arm diverges with error, the Named arm nests a second exhaustive match over an Option, and in evaluate_all an Err arm continues — a divergence, so the surrounding binding still has type int.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close