Pattern Matching
Using TypeScript's discriminated unions and exhaustiveness checking to write code that *cannot* forget a case.
In a language like Haskell, ML, Rust, or Scala, pattern matching
is a first-class syntactic construct. TypeScript doesn't have a
match keyword — but it has something close enough: discriminated
unions + exhaustiveness checking via the never type.
Used well, this pair gives you the central guarantee of pattern matching: the compiler refuses to build code that forgets a case.
The setup: a discriminated union
A discriminated union is a sum type where every variant has a literal tag (called a discriminant) on the same property name.
Inside case "circle":, the compiler narrows s to the circle
variant — s.radius works; s.side doesn't. That narrowing is the
heart of pattern matching in TypeScript.
The exhaustiveness trick: never
Now we add the safety net. Inside the default: branch of a
switch, the value's type should be never — because we've
covered every case. If a future change adds a new variant and we
forget to handle it, the type stops being never and the compiler
catches it.
Try adding | { kind: "triangle"; base: number; height: number }
to Shape (locally on your machine) and re-running the type
checker — the default branch starts erroring, because s is no
longer never. The compiler has told you, before you ship, that
you forgot a case.
This is "compiler-assisted correctness" in its purest form. The type checker is doing the audit you'd otherwise pay a senior engineer to do in code review.
A match helper for expressions
switch is a statement. Sometimes you want pattern matching as
an expression — for instance, when assigning a result. You can
build a tiny helper that achieves both exhaustiveness and a nice
calling syntax:
The Handlers<T, R> mapped type forces you to provide a handler
for every variant of T. Forget one — the compiler complains
the moment you call match.
This is much closer to what pattern matching feels like in Haskell or Rust: an expression with a per-case handler, type-checked.
Nested matching
Sometimes a variant contains another variant. You can pattern
match through layers by chaining match calls, or by destructuring
inside a case.
Recursive sum types like Json show why exhaustive matching
matters: the data structure has six possible shapes, and the
recursive walk must handle all of them. The compiler enforces it.
Pattern matching over multiple values
True pattern-matching languages also let you match tuples of values:
For more complex tuple cases, you can build a tagged "pair" type and match on it the same way:
type Pair = readonly [Light, boolean];…then switch on a derived discriminant such as
`${current}:${emergency}`.
A visual model
Pattern matching forces you to think of code as a branching function over a finite set of shapes:
The dotted line is the safety: the moment somebody adds a new shape, the diagram literally cannot be drawn anymore — the compiler fails, and the developer is forced to extend every match.
A small challenge
In interpret.ts, finish apply so it handles
every variant of Event using a switch with an exhaustiveness
check in the default branch.
Rules:
{ kind: "deposit", amount }→ balance + amount{ kind: "withdraw", amount }→ balance - amount{ kind: "interest", rate }→ balance * (1 + rate), rounded to 2 decimals{ kind: "reset" }→ 0
Expected output
100
70
73.5
0
What does assigning the matched value to a const _: never in the default branch of a switch accomplish?
It silences ESLint warnings about empty default branches
It throws an error at runtime when an unhandled case occurs
It causes a compile error if a new variant is added to the union and not handled, because the value would no longer be of type never
It improves runtime performance of the switch