Pattern Matching
Pattern matching in March is exhaustive, nested, and deeply integrated with the type system. The compiler verifies that every possible shape of a value is handled.
Basic Match
match expr do Pattern -> result end
match do Bool -> result end -- cond form: no scrutinee, each arm is a boolean
Full form:
match expr do
Pattern1 -> result1
Pattern2 -> result2
_ -> default
end
The _ wildcard matches anything and discards the value.
Pattern Catalog
Wildcards and Variables
_ -- match anything, discard
x -- match anything, bind to x
Literal Patterns
match n do
0 -> "zero"
1 -> "one"
_ -> "other"
end
match b do
true -> "yes"
false -> "no"
end
match s do
"hello" -> "greeting"
"bye" -> "farewell"
other -> "unknown: " ++ other
end
Constructor Patterns
type Shape = Circle(Float) | Rect(Float, Float)
match shape do
Circle(r) -> 3.14159 *. r *. r
Rect(w, h) -> w *. h
end
Nullary constructors match with no parens:
-- March has no hexadecimal integer-literal syntax (`0xFF0000` is a lex
-- error — `0` followed by an unrelated identifier `xFF0000`); use decimal.
match color do
Red -> 16711680
Green -> 65280
Blue -> 255
end
Option and Result
match opt do
None -> "nothing"
Some(v) -> "got: " ++ to_string(v)
end
match result do
Ok(v) -> "success: " ++ to_string(v)
Err(e) -> "error: " ++ e
end
Tuple Patterns
match pair do
(0, _) -> "starts with zero"
(_, 0) -> "ends with zero"
(a, b) -> int_to_string(a + b)
end
List Patterns
match xs do
[] -> "empty"
[x] -> "one: " ++ to_string(x)
[x, y] -> "two elements"
Cons(h, t) -> "head is " ++ to_string(h)
_ -> "longer"
end
[] desugars to Nil. [a, b, c] desugars to Cons(a, Cons(b, Cons(c, Nil))).
Record Patterns
A record pattern destructures a record’s fields directly in a match arm,
a let binding, or a function parameter:
type Point = { x : Float, y : Float }
fn describe_point(p : Point) : String do
match p do
{ x: 0.0, y: 0.0 } -> "origin"
{ x: x, y: 0.0 } -> "on x-axis at " ++ float_to_string(x)
{ x: x, y: y } -> "at " ++ float_to_string(x) ++ ", " ++ float_to_string(y)
end
end
A field written as a bare name is shorthand — punning — for name: name,
mirroring record-literal punning: { x, y } binds x and y to the
record’s x and y fields, exactly like { x: x, y: y }. The example
above could equally be written { x: x, y: y } -> ... or, punned, as
fn describe_point({ x, y }) if the whole function dispatched on the
struct shape rather than matching in the body.
Field lists are open
A record pattern need only name the fields it cares about. { x: a } matches
any record that has (at least) an x field, whatever else it carries; fields
the pattern doesn’t mention are simply not bound. The y-axis check above, for
instance, needn’t mention y at all:
match p do
{ x: 0.0 } -> "on y-axis"
_ -> "elsewhere"
end
let { x: px } = p -- `p` may have any number of other fields
let? { x: px } = fetch() -- likewise, against the Ok payload's type
This works wherever the pattern is matched against a value whose type is
already known — a match scrutinee, a let or let? right-hand side, a
constructor argument, a tuple element.
Naming a field the record does not have is a compile error, not a silent no-op, so a typo is caught rather than quietly matching nothing:
This record has no field `xx`.
Available fields: x, y
Composing with other patterns
Record patterns nest anywhere a pattern can appear, which is where they earn their keep. A record inside a record:
type Origin = { host : String, port : Int }
type Route = { origin : Origin, secure : Bool }
fn where_to(r : Route) : String do
match r do
{ origin: { host: "localhost", port: p } } -> "local:" ++ int_to_string(p)
{ origin: { host: h } } -> "remote:" ++ h
end
end
A record inside a constructor payload — the common shape when a record comes
back wrapped in Option or Result:
type Reply = { status : Int, body : String }
fn handle(r : Option(Reply)) : String do
match r do
Some({ status: 200, body: b }) -> "ok " ++ b
Some({ status: s }) -> "http " ++ int_to_string(s)
None -> "no response"
end
end
As or-pattern alternatives, binding the same name from either side:
match reply do
{ status: 404, body: b } | { status: 410, body: b } -> "gone: " ++ b
{ body: b } -> b
end
Under an as pattern, to name the whole record while still destructuring it:
match reply do
{ status: 500 } as whole -> "ALERT " ++ whole.body
{ body: b } -> b
end
With a guard, when the interesting condition is a range rather than a value:
match reply do
{ status: s } when s >= 500 -> "server error"
{ status: s } when s >= 400 -> "client error"
_ -> "ok"
end
And inside a tuple, which is how you dispatch on two values at once:
match (reply, retries) do
({ status: 200 }, _) -> "done"
(_, 0) -> "giving up"
(_, n) -> "retrying, " ++ int_to_string(n) ++ " left"
end
The one exception: a bare pattern as a parameter
A pattern written directly as a parameter — fn get_w({ w: w }) — is the one
place a record pattern is closed, matching exactly the fields it names.
Parameter patterns can’t carry a type annotation (only name : Type can), so
the pattern is the sole source of its own type: get_w is inferred as taking
exactly { w : Int } and rejects a wider { w: 8, h: 9 }.
Give the parameter a name and a type, then destructure in the body, and the field list opens as everywhere else:
fn get_w(r : { w : Int, h : Int }) : Int do
match r do
{ w: w } -> w -- open: `r` may carry other fields too
end
end
Coverage
Record arms take part in exhaustiveness and redundancy checking like any other pattern. A record has one shape, so the checker treats it much as it treats a tuple: one column per field, recursing into each field’s sub-pattern. A match that handles only some values of a field is reported non-exhaustive rather than failing at runtime:
match p do
{ code: 404 } -> "gone"
end
-- warning: Non-exhaustive pattern match — missing case: { code: _, msg: _ }
Arms naming different subsets of the fields still line up, because the field list comes from the record’s type and any field an arm omits counts as a wildcard for that arm. Redundancy follows the same rule, so a record arm already covered by an earlier one is reported unreachable.
Atom Patterns
Atoms are named constants written with a leading colon — each atom is its own type (see the Type System page for a full introduction).
match status do
:ok -> "success"
:error -> "failure"
:pending -> "in progress"
end
Qualified Constructor Patterns
When multiple modules define constructors with the same name, qualify them
(TypeName.Ctor also works — the diagnostic below suggests that spelling —
but the module-qualified form shown here is equally valid). Note a single
match can only ever see ONE of the colliding constructors per scrutinee (a
scrutinee has one concrete type), so — unlike the sketch above might suggest —
qualification shows up as two separate matches over two separately-typed
values, not as alternate arms of the same match:
mod Http do
type HResult = Ok(String) | Err(String)
end
mod Json do
type JResult = Ok(String) | Err(String)
end
fn describe_http(x : Http.HResult) : String do
match x do
Http.Ok(resp) -> handle_http(resp)
Http.Err(_) -> "http error"
end
end
fn describe_json(x : Json.JResult) : String do
match x do
Json.Ok(data) -> handle_json(data)
Json.Err(_) -> "json error"
end
end
Known compiler bug, compiled backend only: the qualified form is only reliably safe here because both colliding
Ok/Errconstructors above carry aStringpayload (same runtime representation). If the two colliding types’ same-named constructor carries payloads with a different representation (e.g. one module’sOk(Int)vs another’sOk(String)), the compiled backend nondeterministically crashes (march: out of memory) or returns garbage data even though every reference is correctly module-qualified and the interpreter is always correct. Minimal repro: nestmod A do type TA = Ok(Int) | Err(String) endandmod B do type TB = Ok(String) | Err(String) endin one file, then compile and run a function that pattern-matchesB.Ok(data) -> dataon aB.Ok("hi")value — the same-shape-payload case (bothOk(Int), or bothOk(String), as above) is unaffected.
Negative Integer Patterns
match n do
-1 -> "minus one"
0 -> "zero"
1 -> "one"
_ -> "other"
end
As Patterns
p as name binds name to the entire matched value while p still
destructures it, so a branch can use both the whole value and pieces of it
without matching it twice:
match o do
Some(x) as whole ->
-- `x` is the payload; `whole` is the entire `Some(x)` value
println(whole)
x
None -> 0
end
The inner pattern can be anything — a bare variable (x as y binds both x
and y to the same value), a literal, a tuple, or an arbitrarily nested
constructor pattern. As-patterns work in match arms, let bindings (let
(n as whole) = compute()), and function parameters, since all three desugar
through the same pattern grammar.
Chaining aliases directly (p as a as b) is a parse error. Parenthesize to
bind two names to the same value — (x as a) as b is accepted — though one
alias per pattern is almost always what you want.
Note the parentheses in the let example above: let takes a simple
pattern, so let n as whole = ... does not parse. let (n as whole) = ...
does.
Or Patterns
p1 | p2 | p3 matches an arm against several alternatives, trying each in
order and running the shared arm body on the first one that matches:
match n do
1 | 2 | 3 -> "small"
_ -> "big"
end
match color do
Red | Green -> "warm"
Blue -> "cool"
end
Alternatives can be literals, nullary/atom constructors, or any other pattern shape, and they may bind variables — provided every alternative binds the same names at the same types:
type E = A(Int) | B(Int) | C
match e do
A(x) | B(x) -> x * 10 -- `x` comes from whichever alternative matched
C -> 0
end
All alternatives share ONE arm body, which reaches its binders as parameters, so the names have to line up. Two ways they can fail to:
match e do
A(x) | B(y) -> x + y -- REJECTED: "Or-pattern alternatives must bind the
end -- same variables." `y` would be unbound
-- whenever `A` matched.
type E2 = A(Int) | B(String)
match e2 do
A(x) | B(x) -> 0 -- REJECTED: `x` cannot be both Int and String
end
If alternatives genuinely need to bind different things, split them into separate arms:
match e do
A(x) -> f(x)
B(s) -> g(s)
end
Exhaustiveness and redundancy checking see through or-patterns at any
nesting depth: Red | Green followed by a Blue arm is exhaustive for a
three-constructor Color, Some(1 | 2) covers exactly Some(1) and
Some(2) (so a match with only that arm and None is still reported
non-exhaustive), and an arm that only repeats alternatives already covered
by an earlier arm is flagged as unreachable exactly as any other redundant
arm would be. An arm whose nested alternatives multiply out to a
pathologically large number of shapes (more than a few hundred) falls back
to being treated as a wildcard for coverage purposes, which can only
suppress a diagnostic, never invent one.
An or-pattern nests beneath as: 1 | 2 as n parses as (1 | 2) as n
(binding n to the whole matched value, alongside anything the alternatives
themselves bind).
Guards
Guards add a boolean condition to a pattern arm with when:
match n do
x when x < 0 -> "negative"
x when (x == 0) -> "zero"
x when x < 100 -> "small positive"
_ -> "large positive"
end
Note (resolved): an earlier version of this document warned that a guard expression using
==/!=/>=/<=/&&/||/++needed parenthesizing when followed by another guarded arm, due to a parser arm-boundary-lookahead limitation. Verified live: this no longer reproduces — chained guards using any of these operators, with no parens, parse and evaluate correctly. The parens in the example above are harmless but no longer necessary.
Guards on function heads work the same way:
fn classify(n) when n < 0 do "negative" end
fn classify(n) when n == 0 do "zero" end
fn classify(n) do "positive" end
Exhaustiveness Checking
The compiler verifies that every possible value is matched. If you miss a case, you get a diagnostic pointing at exactly what’s missing — verified live, it is currently a warning, not a hard compile error (the program still typechecks at exit 0 and still runs; a value that actually hits the missing case panics at runtime, same as any other non-exhaustive match):
type Color = Red | Green | Blue
-- WARNING: Non-exhaustive pattern match — missing case: Blue
match color do
Red -> "red"
Green -> "green"
end
Add a wildcard or the missing case to fix it:
match color do
Red -> "red"
Green -> "green"
Blue -> "blue"
end
Exhaustiveness extends to nested patterns. The compiler understands which combinations are possible.
Why this matters — refactoring safety. Exhaustiveness turns “add a variant”
from a silent hazard into a guided checklist. Add a Blue case to a Color that
already had Red and Green, and the compiler flags every match in the
codebase that forgot to handle it — each one a precise diagnostic (currently a
warning, not a hard error — see above) pointing at the spot to update. (The LSP
even offers an “Add all N missing cases” quick fix.) A stale match arm doesn’t
fail the build, but it also can’t hide silently; the type that changed pulls
every dependent decision back into view.
Nested Patterns
Patterns can be nested arbitrarily deep:
type Tree(a) = Leaf | Node(Tree(a), a, Tree(a))
fn depth(t : Tree(a)) : Int do
match t do
Leaf -> 0
Node(Leaf, _, Leaf) -> 1
Node(l, _, r) -> 1 + Math.max_int(depth(l), depth(r))
end
end
Nested Option:
match (opt_a, opt_b) do
(Some(a), Some(b)) -> a + b
(Some(a), None) -> a
(None, Some(b)) -> b
(None, None) -> 0
end
Multi-Expression Arms
Match arms support multiple expressions — any number of let bindings followed by a final expression:
match result do
Ok(data) ->
let trimmed = String.trim(data)
let upper = String.to_uppercase(trimmed)
println(upper)
true
Err(msg) ->
println("Error: " ++ msg)
false
end
A do ... end wrapper also works for clarity:
match xs do
Cons(h, t) -> do
let doubled = h * 2
Cons(doubled, t)
end
Nil -> Nil
end
Cond (Pattern-Free Multi-Way If)
When match has no scrutinee expression, each arm is a boolean guard — this is equivalent to cond in other languages:
match do
(score >= 90) -> "A"
(score >= 80) -> "B"
(score >= 70) -> "C"
(score >= 60) -> "D"
_ -> "F"
end
Note (resolved): an earlier version of this document warned that each condition needed parenthesizing when chaining two or more cond arms using a comparison/logical operator (
>=,<=,==,!=,&&,||,++), citing an arm-boundary-lookahead parser limitation. Verified live: this no longer reproduces for any of these operators, chained or mixed, with or without a trailing wildcard arm. The parens above are harmless but no longer necessary.
This is equivalent to a chain of if/else but reads more cleanly.
With (Monadic Pattern Matching)
with is for chaining Result/Option bindings without nesting:
with Ok(user) <- authenticate(credentials),
Ok(profile) <- fetch_profile(user.id),
Ok(data) <- load_data(profile.key) do
render(user, profile, data)
else
Err(AuthFailed) -> reply(401, "Unauthorized")
Err(AuthNotFound(kind)) -> reply(404, kind ++ " not found")
Err(AuthTimeout) -> reply(503, "Service unavailable")
end
Note (resolved): an earlier version of this document warned that multiple
elsearms had to be crammed onto one line, separated by|, because splitting them across lines (as above) failed to parse. Verified live: multi-lineelsearms parse and dispatch correctly now — each of the three arms above is independently reachable and gives the right reply for its error case.
Each <- binding: if the expression matches the pattern, execution continues with the binding in scope. On mismatch, control passes to the else block (or the non-matching value propagates if there’s no else).
Patterns in Let Bindings
Patterns work directly in let — but a bare constructor pattern needs an
extra pair of parens (let Some(x) = ... is a parse error; let (Some(x)) =
... is not). A tuple pattern doesn’t need the extra parens,
since let (a, b) = ... is its own grammar production:
let (a, b) = some_pair()
let (Some(x)) = might_be_some() -- panics if None
let (Cons(h, t)) = nonempty_list
And in function parameters:
fn fst((a, _)) do a end
fn snd((_, b)) do b end
fn add_points(p1 : Point, p2 : Point) : Point do
{ x: p1.x +. p2.x, y: p1.y +. p2.y }
end
Multi-head Functions
A function can have multiple clauses that pattern-match directly in the parameter list. Consecutive definitions with the same name are merged by the compiler into a single function that tries each clause in order:
fn factorial(0) : Int do 1 end
fn factorial(n) : Int do n * factorial(n - 1) end
fn describe(0) : String do "zero" end
fn describe(1) : String do "one" end
fn describe(n) : String do
if n < 0 do "negative" else "many" end
end
Clauses are checked top to bottom; the first matching clause wins. The compiler warns if later clauses are unreachable.
Multi-head functions work with any pattern in the parameter list that match
itself supports — constructors, literals, tuples, and record patterns
(see “Record Patterns” above):
fn head(Cons(x, _)) : a do x end
fn head(Nil) : a do panic("empty list") end
fn first((x, _)) : a do x end
when Guards
Add a when clause to a function head (or match arm) to add a boolean condition beyond the pattern:
fn classify(n : Int) : String when n < 0 do "negative" end
fn classify(n : Int) : String when n == 0 do "zero" end
fn classify(n : Int) : String do "positive" end
Guards can also appear on match arms:
match score do
n when (n >= 90) -> "A"
n when (n >= 80) -> "B"
n when (n >= 70) -> "C"
_ -> "F"
end
See the note under “Guards” above — parenthesizing
>=/<=/==/etc. guards when chaining more than one is no longer required (fixed).
A guard that fails causes the clause to be skipped and the next clause is tried. A function with no matching clause (after guards) panics at runtime — make the last clause unconditional or use a wildcard to ensure exhaustiveness.
Next Steps
- Type System — the types you’re matching against
- Tour — language overview with more examples
- Interfaces — polymorphic dispatch with
interface