Skip to content

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.

Match = "match" ( Expr | "(" Expr ":" Type ")" ) "{" { Arm } "}" .
Arm = Pattern [ "if" Expr ] ( Block | "=>" Expr ) . // optional guard; block or expression body
Pattern = 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).

match e { … } dispatches over the value of e, the scrutinee. Its static type selects the matching mode:

Scrutinee typePatternsMatch mechanism
value union.case (with optional payload bindings), defaulttag comparison, then field loads
ref union.case (with optional payload bindings), defaultisinst type test, then field loads
enum.case, defaultunderlying-value comparison
int / string / boolliteral patterns, defaultvalue comparison
open ref / interface / objecttype pattern (name: T), nil, defaultisinst 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()
}

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 { … }
}

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).

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 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.

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}"
}

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 : string

An 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.

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.

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).

The matching mechanism follows the scrutinee’s CLR form (CLR mapping):

  • a value union is 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 union is an abstract base with one sealed subclass per case, so a match lowers to an isinst type test per arm and extracts fields from the matched subclass;
  • an enum lowers to an integer switch;
  • literal patterns lower to value comparisons;
  • a type pattern (name: T) lowers to isinst T per arm (the binding stored with castclass for a reference target, unbox.any for a value one); a nil arm to a null / Nullable<T>.HasValue test. A match carrying a guard, a type pattern, or a nil arm 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.