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:
matchis an expression. Its chosen arm produces a value, so amatchcan be returned, bound to alet, or passed as an argument directly — no accumulator variable, no fall-through.matchis 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 optionalif guard, the arrow=>, and the arm body. - The arm arrow is
=>everywhere in Axle (match, a traitimpl Selftable, 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 thematchproduces 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'or0..=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
ifchain, 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:
| Pattern | Matches |
|---|---|
lo..hi | lo up to but not including hi (exclusive) |
lo..=hi | lo 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-typestructscrutinee does not take one: sema admits it and codegen stops with an internal error rather than a diagnostic. Match on astruct’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
matchwhose 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:
| Scrutinee | Proved exhaustive when… | Otherwise |
|---|---|---|
bool | both true and false appear (unguarded) | needs an irrefutable arm |
enum | every variant appears (unguarded) | needs an irrefutable arm |
i32, char, string, class, T \| null, … | an irrefutable arm is present | needs an irrefutable arm |
An arm is irrefutable in exactly these shapes:
| Arm | Irrefutable 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-nullablePoint. Every listed field binds, and an unlisted field is an implicit wildcard, so the arm matches everyPoint. The earlier example’s third arm therefore needs no_.- The same arm is refutable on
Point | null, becausenullis 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 step | there is nothing to pull apart |
| exhaustiveness should be enforced | the cases are not meant to be total |
| the branch produces a value to bind or return | you 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.5and asks for a pattern. Compare floats with anifchain, 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
structscrutinee is not supported. Sema admitsVec2 { x: 0 }against astruct, 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
- Enums — the value type
matchmost often destructures. - Language tour → Control flow —
matchin the context of the other control constructs. - Generics and traits →
match type T— the compile-time counterpart that switches on a type, not a value. - Reading compiler errors — the E0001 non-exhaustive diagnostic.
- Concept index — every keyword on one page.