Axle v0.14.1

Pattern matching

match chooses one arm by testing a value — the scrutinee — against a list of patterns, top to bottom, and runs the first arm that fits. It is the single construct that unifies three jobs other languages split apart: a multi-way branch (like switch), a value test, and destructuring (pulling a class or enum apart into its parts and binding them to names in one step).

This page covers:

  • the shape of a match — arms, guards, statement vs expression position;
  • every pattern kind (wildcard, literal, binding, const, range, or-pattern, class destructuring, enum variant, null);
  • the exhaustiveness rule the compiler enforces, and what makes an arm total.

The tour introduces match in a paragraph; this page is the complete reference.

Two properties make it more than a switch:

  • match is an expression. Its chosen arm produces a value, so a match can be returned, bound to a let, or passed as an argument directly — no accumulator variable, no fall-through.
  • match is checked for exhaustiveness. For the types the compiler can reason about completely, a missing case is a compile error, not a value that silently slips through at runtime.

Anatomy of a match

match scrutinee {
    pattern           => arm_body,
    pattern if guard  => arm_body,
    _                 => arm_body,
}
  • The scrutinee is the value being examined — any expression.
  • Each line is an arm: a pattern, an optional if guard, the arrow =>, and the arm body.
  • The arm arrow is => everywhere in Axle (match, a trait impl Self table, a lambda). It is never ->.
  • Arms are tried top to bottom; the first whose pattern (and guard, if any) matches wins, and no later arm runs.

The arm body is an expression whose value becomes the value of the whole match:

fn classify(x : i32) : string {
    return match x {
        0      => "zero",
        1..=9  => "small",
        _      => "large",
    };
}

When you use match in statement position — for its side effects, not its value — an arm may instead be a { block } of statements:

fn main() : i32 {
    let x : i32 = 2;
    match x {
        1 => { println("one"); }
        2 => { println("two"); }
        _ => { println("other"); }
    }
    return 0;
}

Note — a { block } arm runs statements for their effect; it does not yield a value the way a Rust block does. In expression position — where the match produces a value — write an expression arm (2 => "even"), not a block.

The pattern kinds

Every pattern below is a real, compilable form. They combine freely: a class pattern can nest a range, an or-pattern can hold a mix of literals and ranges, any arm can carry a guard.

Wildcard — _

_ matches any value and binds nothing. It is the catch-all, and by convention the last arm:

fn classify(x : i32) : string {
    return match x {
        0 => "none",
        1 => "one",
        _ => "many",       // everything else
    };
}

Literal patterns

An integer or boolean literal matches by equality:

fn status(code : i32) : string {
    return match code {
        200 => "ok",           // arm bodies may be any type — here strings
        404 => "not found",
        _   => "other",
    };
}

fn bit(flag : bool) : i32 {
    return match flag {        // bool needs no `_` — see Exhaustiveness
        true  => 1,
        false => 0,
    };
}

A string or char scrutinee matches literals of its own kind:

fn codeOf(command : string) : i32 {
    return match command {
        "start" => 1,
        "stop"  => 2,
        _       => 0,
    };
}

fn kindOf(c : char) : string {
    return match c {
        'a'..='z' => "lower",
        '0'..='9' => "digit",
        _         => "other",
    };
}

String arms compare content, not addresses — a string built at runtime matches the literal it equals. char is a 32-bit codepoint, so a char range orders by codepoint.

A range’s two bounds must be the same kind: 'a'..='z' or 0..=9, never 'a'..9. Both spellings lower to a 32-bit integer, so a mixed range would compile into a comparison that runs and means nothing.

A float literal in pattern position is still unsupported. Compare floats with an if chain, where the tolerance is written down.

Binding patterns

A bare lowercase name binds the scrutinee to a new local, usable in the arm body. A binding matches anything, so it is irrefutable:

fn triple(x : i32) : i32 {
    return match x {
        0 => 0,
        n => n * 3,        // `n` binds the value; matches every non-zero x
    };
}

Because a top-level binding matches everything, it plays the same exhaustiveness role as _ — with the difference that it gives the value a name.

Const-equality patterns

A name in pattern position usually binds. But if the name resolves to a const in scope, the pattern becomes an equality test against that constant’s value — SPACE => … behaves like x == SPACE:

fn classify(c : i32) : i32 {
    const SPACE   : i32 = 32;
    const TAB     : i32 = 9;
    const NEWLINE : i32 = 10;
    return match c {
        SPACE   => 1,
        TAB     => 2,
        NEWLINE => 3,
        _       => 0,      // still needed — the three consts don't cover i32
    };
}

This is what lets you match against named constants instead of scattering magic numbers through the arms. A plain name with no const of that name in scope falls back to a binding.

Range patterns

A range pattern matches a span of integers. Both range forms from the operators carry over:

PatternMatches
lo..hilo up to but not including hi (exclusive)
lo..=hilo up to and including hi (inclusive)
fn size(n : i32) : string {
    return match n {
        0..10   => "small",     // 0..=9
        10..=99 => "medium",    // includes 100? no — up to 99
        _       => "large",
    };
}

The bounds need not be literals — any numeric expression works, so a range pattern can be keyed on locals:

fn inBand(value : i32, lo : i32, hi : i32) : i32 {
    return match value {
        lo..=hi => 1,          // matches when lo <= value <= hi
        _       => 0,
    };
}

Or-patterns — A | B | C

An or-pattern lets one arm cover several alternatives. The arm fires when any alternative matches. Alternatives may mix literals and ranges:

fn classify(n : i32) : i32 {
    return match n {
        1 | 3 | 5        => 1,     // three literals
        2 | 4 | 6        => 2,
        0 | 7..=9 | 100  => 3,     // literal + range + literal in one arm
        _                => 4,
    };
}

Restriction — an or-pattern may not bind a value on any branch. The compiler rejects Some(x) | None, because the None branch would leave x uninitialised. Match the tag alone (Some(_) | None) or split the binding into its own arm.

Class destructuring

A Class { field: pattern, … } pattern tests a class scrutinee field by field. Each listed field is read and matched against its sub-pattern; unlisted fields are implicit wildcards. A field’s sub-pattern can be a literal (test), a binding (capture), or a nested class pattern.

A destructuring pattern reads the named fields through the field’s own visibility. A field reached from outside its declaring class needs a marker — pub, or one of the narrower rungs — exactly as a field read in an expression does:

class Point {
    pub x : i32;
    pub y : i32;
    constructor(x : i32, y : i32) {
        self.x = x;
        self.y = y;
    }
}

fn describe(p : Point) : i32 {
    return match p {
        Point { x: 0, y: 0 } => 1,       // both fields tested
        Point { x: 0 }       => 2,        // y unlisted → any y
        Point { x: xv }      => xv * 10,  // binds x, ignores y — irrefutable
    };
}

Patterns nest to any depth — an outer field can itself be a class pattern, and a deep binding captures the innermost value:

class Inner {
    pub value : i32;
    constructor(v : i32) { self.value = v; }
}

class Outer {
    pub inner : Inner;
    constructor(own i : Inner) { self.inner = i; }
}

fn dig(o : Outer) : i32 {
    return match o {
        Outer { inner: Inner { value: v } } => v,   // v = the innermost i32
        _                                   => 0,   // a nested pattern is refutable
    };
}

Note — this pattern shape is for class, whose scrutinee is a pointer. A value-type struct scrutinee does not take one: sema admits it and codegen stops with an internal error rather than a diagnostic. Match on a struct’s fields by reading them (if (p.x == 0)).

Enum variant patterns

An enum scrutinee matches by variant, and a payload-carrying variant destructures its payload into bindings in the same step:

enum Shape { Circle(i32), Rect(i32, i32), Empty }

fn area(s : Shape) : i32 {
    return match s {
        Shape::Circle(r)  => r * r * 3,
        Shape::Rect(w, h) => w * h,      // binds both payload fields
        Shape::Empty      => 0,
    };
}

A variant may be named bare — Circle(r) or Empty without the Shape:: prefix — and it still resolves against the scrutinee’s enum as that variant, not as a fresh binding. Enums have their own chapter — see Enums for construction, payloads, and methods.

The null pattern

null in pattern position matches the null case of a nullable (T | null) scrutinee. The scrutinee’s type must be nullable — a non-null binding can never equal null, so the compiler rejects the pattern on a non-nullable type:

class Box { pub value : i32, constructor(v : i32) { self.value = v; } }

fn unwrap(b : Box | null) : i32 {
    return match b {
        null             => -1,
        Box { value: v } => v,     // refutable here — see Exhaustiveness
        _                => 0,
    };
}

Guards — pattern if condition

A guard adds a boolean side-condition to an arm: the arm fires only when the pattern matches and the guard is true. If the guard is false, matching continues to the next arm. Guards let one binding drive several outcomes:

fn sign(n : i32) : i32 {
    return match n {
        x if x < 0 => 0 - 1,     // any negative
        0          => 0,
        _          => 1,          // the remaining positives
    };
}

A guard can read the bindings the pattern introduced, which is what makes x if x < 0 work — x is bound first, then tested.

Guards do not count toward exhaustiveness. The compiler can’t prove a runtime condition covers every value, so a match whose only non-_ arms are guarded still needs an irrefutable arm. match n { x if x > 0 => 1, x if x <= 0 => 2 } is rejected as non-exhaustive even though the two guards look total — add a _ or a bare binding.

Exhaustiveness — the rule

A match must be exhaustive: for every possible scrutinee value, some arm must match. A match that can fall through every arm is rejected at compile time with E0001 (“match is not exhaustive”). This is the guarantee that a new enum variant, or a forgotten case, becomes a build error instead of a silent gap.

An arm is irrefutable when it matches every value of the scrutinee’s type. A match is exhaustive when it either contains an irrefutable arm, or the compiler can prove the arms cover the type:

ScrutineeProved exhaustive when…Otherwise
boolboth true and false appear (unguarded)needs an irrefutable arm
enumevery variant appears (unguarded)needs an irrefutable arm
i32, char, string, class, T \| null, …an irrefutable arm is presentneeds an irrefutable arm

An arm is irrefutable in exactly these shapes:

ArmIrrefutable when…
_ (wildcard)always
a bare binding (other => …)always
an or-pattern (A \| B)any branch is irrefutable — 1 \| _ is total
a class pattern (Point { x: xv })every listed field binds or ignores its value, and the scrutinee is not nullable

A guard takes an arm out of this count: the compiler cannot prove a runtime condition covers anything, so a guarded arm never contributes to exhaustiveness (see Guards).

Two consequences that surprise people:

  • Point { x: xv } is total on a non-nullable Point. Every listed field binds, and an unlisted field is an implicit wildcard, so the arm matches every Point. The earlier example’s third arm therefore needs no _.
  • The same arm is refutable on Point | null, because null is a value of the scrutinee’s type and the pattern can miss it. Nested patterns are refutable for the same reason: Outer { inner: Inner { … } } carries an inner pattern the compiler does not descend into, so a _ stays required.
enum Direction { North, East, South, West }

class Point {
    pub x : i32;
    pub y : i32;
    constructor(x : i32, y : i32) { self.x = x; self.y = y; }
}

fn main() : i32 {
    let ready : bool = true;
    let dir : Direction = Direction::North;
    let x : i32 = 7;
    let p : Point = new Point(0, 1);

    // bool: exhaustive with no `_`
    match ready { true => 1, false => 0 }

    // enum: exhaustive with no `_` when all variants are named
    match dir { North => 0, East => 1, South => 2, West => 3 }

    // i32: a bare binding is irrefutable → exhaustive
    match x { 0 => 0, n => n * 3 }

    // class: a literal test is refutable, so a `_` is required …
    match p { Point { x: 0 } => 1, _ => 0 }

    // … while a pattern that binds every listed field is total on its own
    match p { Point { x: a, y: b } => a + b }

    return 0;
}

When to reach for match

match and if / else if overlap, but each reads better for a different shape:

Reach for match when…Reach for if when…
branching on a closed set (an enum, a fixed set of codes)testing an open boolean condition
you want destructuring in the same stepthere is nothing to pull apart
exhaustiveness should be enforcedthe cases are not meant to be total
the branch produces a value to bind or returnyou branch for side effects only

An enum plus an exhaustive match is the idiomatic way to model a closed set of states: add a variant, and every match that forgot to handle it fails to compile — the compiler becomes your checklist.

Limitations

  • No float literal in pattern position. The parser stops at 1.5 and asks for a pattern. Compare floats with an if chain, where the tolerance is written down.
  • A class pattern reads fields, not methods. Only stored fields can be listed; a computed property has no slot for the pattern to load.
  • A destructured field needs its visibility marker. An unmarked field belongs to its declaring class, so a pattern written elsewhere stops with E0004 — the same rule as a field read.
  • A value-type struct scrutinee is not supported. Sema admits Vec2 { x: 0 } against a struct, and codegen stops with an internal error rather than a diagnostic. Read the fields directly instead.
  • Exhaustiveness stops at the outer pattern. The compiler descends into an or-pattern, but not into a nested class pattern: Outer { inner: Inner { value: v } } counts as refutable, so a redundant _ is required. The miss is in the safe direction — an extra arm, never a fall-through.

See also

matchpatterndestructuringguardsexhaustivenesscontrol-flow