Skip to content

Pattern matching

Pattern forms, as bindings, exhaustiveness rules, the structural record subset, and tuple scrutinees.

Updated View as Markdown

match is an expression. It has no fallthrough, and its arms are checked for coverage and redundancy.

Pattern forms

Form Spelling
Wildcard _
Binding name
Literal "paid", 42, true
Constructor Paid({ method }), Ident(text)
Array [first, ...rest]
Record { kind: "created", payload: { id } }
Or-pattern LParen | RParen
as binding Paid(_) as p
Guard Paid({ method }) if method == "ideal"

as bindings

pat as name names the value that pat matched. It takes a single identifier and binds looser than | (so A | B as x names whichever alternative matched). Root aliases and the supported constructor-payload positions work in match arms and binding forms. A whole structural-record alias also works in a match arm: it names the original object, narrowed by that arm’s tests. The structural-record subsection below lists the remaining nested-field restrictions.

An as binding is transparent to exhaustiveness and redundancy checking, since it matches exactly when its pattern matches. It lowers to a const in the arm or binding prologue, bound to the matched position:

Matched position What the alias binds
Arm root The whole scrutinee
Array or record element The indexed or property child
Single positional payload The value field
Record payload The narrowed variant object itself, _tag included

Exhaustiveness

Coverage is checked for Hem variants, boolean, closed literal unions, and ?T. Those four are closed sets the compiler can enumerate, so a match over one needs no catch-all.

Record patterns are not among them. Structural object space is open — a value can always carry fields the pattern does not name — so a match whose arms are record patterns needs a final _ even when the union’s discriminant is a literal type and the arms look complete. Omitting it is HEM2005.

The specification’s §7 states the broader rule, extending coverage to any discriminated union with literal discriminants, imported ones included. That is the intended destination; this page describes what the compiler does today.

Missing coverage, an open set with no catch-all, and redundant arms all report HEM2005. Unreachable arms warn.

Emit is a switch on the detected discriminant when the match is flat. A default: throw appears only where the Hem frontend proved coverage that TypeScript cannot.

Structural record patterns

A record arm matches ordinary API, database, and event objects without converting them into Hem variants. The supported subset is deliberately narrow.

Permitted:

  • Record alternatives at arm and field level, binding the same names
  • Literal fields
  • Punned or explicitly bound fields
  • Wildcards
  • Nested record patterns built from the same pieces
  • An as alias on the whole arm pattern or a nested matched position

Literal tests run before nested reads, so TypeScript narrows a discriminated union before the arm binds its payload.

let describe = (event: Event): string => match event {
| { kind: "created", payload: { id } } => `created:${id}`
| { kind: "deleted", id } => `deleted:${id}`
| _ => "unknown"
}

Emit is an if/else if chain with plain const field bindings.

Project compilation checks finite literal-discriminant unions, including imported unions. An unguarded record pattern covers a whole variant when its discriminator matches and its remaining fields are unrestricted bindings or wildcards. Binding an optional field reads its possibly absent value; it does not test presence. Record alternatives and literal alternatives within the discriminator contribute coverage. Runtime guards, payload literals, and nested record restrictions remain partial; this bounded check does not prove arbitrary predicates.

type Result = { success: true, data: number } | { success: false, error: string }
let describe = (result: Result): string => match result {
| { success: true, data } => data.toFixed(2)
| { success: false, error } => error
}

Without a fallback, adding an uncovered variant produces HEM2005. An intentional final fallback is legal on a closed union and warns if currently unreachable; open domains still require it. Checker-free single-file compilation cannot establish the closed domain and requires a fallback. No new pattern syntax is involved.

type Change = { kind: "created", id: number } | { kind: "deleted", reason: string }

let createdId = (change: Change): number => match change {
| { kind: "created" } as created => created.id
| _ => 0
}

created is the same object as change, not a reconstructed record. The scrutinee is evaluated once, and the alias is available in the arm’s guard and body.

Alternatives bind exactly the same names with checked types. The leftmost complete match supplies the bindings. The guard runs once; if false, matching advances to the next arm, not another alternative in the same arm. Constructor alternatives follow the same rule.

type Change = { kind: "created", id: number } | { kind: "deleted", previousId: number }
let changeId = (input: { change: Change }): number => match input {
| { change: { kind: "created", id } | { kind: "deleted", previousId: id } as selected } => id
| _ => 0
}

selected names the matched child object, preserving identity and readonly access. A non-final fallback and nested array or constructor patterns remain outside the structural subset.

Guarded value-producing matches use a labeled block so a successful arm assigns the result and exits. A false guard continues to the next arm. This also lets TypeScript see definite assignment; the compiler does not silence an uninitialized result with a type assertion. A final unreachable throw is emitted only when Hem has proved coverage that TypeScript cannot see.

Tuple scrutinees

match [a, b] { … } matches two or more scrutinees at once. It is syntax, not a value: no runtime array is built. Minimum arity is two, since one element is just match a.

Each element evaluates once. An identifier or typeof x element stays inline; anything else lands in a temporary. Every arm is a fixed-length array pattern whose columns test independently, emitting a conjunctive if/else chain.

let step = (state: State, event: Event): State =>
  match [state, event] {
  | [Idle, Start] => Loading
  | [Loading, Finish] => Ready
  | [Ready | Done, Reset] => Idle
  | [_, _] => state
  }

Per-column or-patterns, constructor payloads, and per-column as bindings are unrestricted.

Exhaustiveness generalizes column by column and names the missing combinations, capped at three followed by a count. An all-wildcard arm is the catch-all.

HEM2019 refuses three shapes: a rest element (arms are fixed-length), an as binding over the whole array pattern (there is no tuple value to name), and an arity mismatch between an arm and the scrutinee.

Code Meaning
HEM2005 Missing coverage, open set without a catch-all, or a redundant arm
HEM2016 Ambiguous pattern binding
HEM2019 Tuple-scrutinee arm shape
HEM3003 Pattern outside a supported subset

Normative source

Specification 0.2, §7 (control flow). The exhaustiveness and redundancy algorithm is specified alongside it.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close