Pattern matching
match is E#‘s single dispatch-and-destructure construct. It is both a statement and an expression,
and it is the only way a union is consumed. This page
specifies it completely; the grammar is repeated from Statements → match.
In this specification
Section titled “In this specification”- Patterns, bindings, and guards specifies the legal pattern forms, arm-local names, type patterns, and guard sequencing.
- Exhaustiveness and lowering specifies closed-set coverage, definite return, and the CLR dispatch forms.
Match = "match" ( Expr | "(" Expr ":" Type ")" ) "{" { Arm } "}" .Arm = Pattern [ "if" Expr ] ( Block | "=>" Expr ) . // optional guard; block or expression bodyPattern = DotCase [ "(" [ Binding { "," Binding } ] ")" ] // union / enum case + payload bindings | "(" identifier ":" Type ")" // type pattern — `is T` + bind the narrowed value | literal // int / string / bool literal pattern | "nil" // the absent-value arm of a T? / reference scrutinee | "default" .DotCase = "." identifier .An arm carries an optional guard (if Expr) that refines it — the arm matches only when the
pattern matches and the guard holds, otherwise control falls to the next arm — and either a { … }
block body or a => Expr expression body (the value form, also used in expression position).
The scrutinee
Section titled “The scrutinee”match e { … } dispatches over the value of e, the scrutinee. Its static type selects the
matching mode:
| Scrutinee type | Patterns | Match mechanism |
|---|---|---|
value union | .case (with optional payload bindings), default | tag comparison, then field loads |
ref union | .case (with optional payload bindings), default | isinst type test, then field loads |
enum | .case, default | underlying-value comparison |
int / string / bool | literal patterns, default | value comparison |
open ref / interface / object | type pattern (name: T), nil, default | isinst per arm, then bind the narrowed value |
A type pattern (name: T) is is T plus a binding — it dispatches an open scrutinee (a base
class, an interface, object) to a concrete type, binding name to the narrowed value. It is the
OO-hierarchy sibling of the .case form: where a ref union already lowers to one isinst per case,
a type-pattern match lowers to one isinst per arm over a hierarchy of real subclasses (identity,
virtual methods, framework interop) rather than payloads. A union/enum is closed and is matched by
.case, never by type (ES2172). The full narrowing model — is / as /
as! and smart-casting — is in Type system → Conversions.
match sym { // sym : Sym, an abstract class base (t: TypeSym) => "type {t.name}/{t.arity}" (m: MethodSym) if m.isStatic => "static {m.name}" // a guard refines the arm (m: MethodSym) => "func {m.name}"}
match o { // o : object — open world, `default` required nil => "nil" (n: int) => "int {n}" // a value-type target unboxes (s: string) => "str {s}" default => o.ToString()}Type ascription
Section titled “Type ascription”The bare scrutinee form suffices whenever the compiler can name the scrutinee’s type. The ascribed form
match (e: T) { … } is required only when that static type is ambiguous — for example a value reaching
the match through object, or an enum value arrived at by a bare .case with no other type
context. The ascription names the type the cases are resolved against; it performs no conversion.
match shape { // shape : Shape (a union) — bare form .circle(r) { … } .rect(w, h) { … }}
match (tag: Direction) { // ascribed — tag's static type would otherwise be unclear .north { … } .south { … } default { … }}Case patterns and payload binding
Section titled “Case patterns and payload binding”A .case pattern matches one variant of a union / ref union / enum. Payload fields are bound in
the parentheses, in one of two forms:
Positional binding names each payload field in declaration order:
union Log { info(text: string), entry(level: int, text: string) }
match e { .info(t) { write(t) } // t : string .entry(lvl, t) { write("{lvl}: {t}") } // lvl : int, t : string}Case-view binding (value union only) binds a single name whose members are the payload fields,
projected by their declared names:
match e { .entry(v) { write("{v.level}: {v.text}") } // v is a view; v.level, v.text are the payloads}For a single-payload case the view is transparent: the one binding doubles as both the view and
the payload value, so .info(t) lets t be used directly as the string and, were info’s payload
itself a struct, t.field would project that struct’s fields. This is why .connected(sid) (use the
payload directly) and .accepted(a) with a.id (project a field) both read naturally from the same
single binding.
A .case with no parentheses matches a payload-less case (an enum case, or a union case declared
with no fields).
Literal patterns
Section titled “Literal patterns”When the scrutinee is int, string, or bool, an arm may be a literal that matches by value:
match code { 200 { return ok(body) } 404 { return error(.notFound) } default { return error(.other(code)) }}Literal matching is over an open set, so exhaustiveness does not apply — a literal match must
carry a default to cover the remaining values, or the non-void-return analysis (below)
will reject a function that relies on it.
default and exhaustiveness
Section titled “default and exhaustiveness”default matches any value not matched by an earlier arm. For a match over a union / ref union
/ enum, omitting a variant (with no default) is warned, not an error: the compiler knows the
closed set of cases and reports the gap, and a default arm suppresses the warning by covering the
remainder. Arms are tested top to bottom; the first matching arm wins, so an earlier arm shadows a later
duplicate. A guarded arm never counts toward exhaustiveness — its guard may be false, so the case it
names is not fully covered until an unguarded arm (or a default) handles it.
Hierarchy exhaustiveness
Section titled “Hierarchy exhaustiveness”A type-pattern match over an abstract class base whose leaves (plain, already-sealed class)
are all in this assembly is exhaustive-checked exactly like a union: the compiler collects the
base’s in-assembly concrete descendants and warns on any leaf no arm covers — so adding a leaf lights up
every match that predates it. Full coverage needs no default and satisfies definite return. An open
base (instantiable and inheritable) opens the world, so a match over it requires a default, like a
literal match. This is the closed-world dispatch form — preferring it (or a ref union) in your own
APIs lets a consumer dispatch with an exhaustive match and no downcasts.
abstract class Sym { name: string init(n: string) { self.name = n } }class TypeSym : Sym { arity: int init(n: string, a: int) : base(n) { self.arity = a } }class MethodSym : Sym { isStatic: bool init(n: string, s: bool) : base(n) { self.isStatic = s } }
func describe(s: Sym) -> string = match s { // exhaustive over Sym's leaves — warns if one is added (t: TypeSym) => "type {t.name}/{t.arity}" (m: MethodSym) => "func {m.name}" }match as an expression
Section titled “match as an expression”In expression position every arm is an expression body (Pattern Expr) — or a block whose value is
produced by its terminating expression — and all arms shall yield one common type, which becomes the
type of the match expression:
let label = match dir { .north => "up" .south => "down" .east => "right" .west => "left"} // label : stringAn expression match is most useful when it is exhaustive, so no default is needed and the result is
total. A non-exhaustive expression match over a closed set is still only warned, but leaves the
“missing variant” path with no value — supply the missing arms or a default.
Binding scope
Section titled “Binding scope”Names bound by a pattern (positional payloads, a case view) are in scope only within that arm’s body.
They do not leak to sibling arms or past the match. Nested matches introduce their own bindings, which
shadow outer ones by the usual block-scoping rules. A pattern binding is immutable within its arm.
match and definite return
Section titled “match and definite return”The non-void-return analysis (Statements → return) is
match-aware: an exhaustive match whose every arm terminates (returns, throws, or loops forever)
counts as returning, so no redundant trailing return is required after it. A match that is not
exhaustive — a literal match, or a case match missing variants — does not satisfy definite return on its
own; the function needs a default arm (or a following return) to be well-formed
(ES2140).
Lowering
Section titled “Lowering”The matching mechanism follows the scrutinee’s CLR form (CLR mapping):
- a value
unionis a tag enum plus a struct, so a match lowers to a comparison (or jump table) on the tag, and each arm loads the payload fields it binds after the tag check; - a
ref unionis an abstract base with one sealed subclass per case, so a match lowers to anisinsttype test per arm and extracts fields from the matched subclass; - an
enumlowers to an integerswitch; - literal patterns lower to value comparisons;
- a type pattern
(name: T)lowers toisinst Tper arm (the binding stored withcastclassfor a reference target,unbox.anyfor a value one); anilarm to a null /Nullable<T>.HasValuetest. A match carrying a guard, a type pattern, or anilarm lowers as a linear top-to-bottom test chain (so a guard can fall to the next arm) rather than a jump table.
A guard evaluates after its arm’s pattern matches and binds; on false, control falls to the next arm.
Case-view and transparent-single-payload binding are purely a binder projection — they introduce
synthetic locals for the payload fields and rewrite view.field to those locals — with no extra runtime
cost over positional binding.