Ask a working functional programmer what makes their language feel tight — why a whole class of bugs simply cannot happen — and the answer is almost always the same two features working as one: algebraic data types and pattern matching. An ADT lets you say "a shape is a circle or a rectangle or a triangle, and here is exactly what data each one carries." Pattern matching then forces you to say what to do in every case — and the compiler complains if you forget one. Together they turn "did I handle all the cases?" from a nagging worry into a mechanically checked fact.
The name "algebraic" is not decoration. These types are built by exactly two operations — a
sum (a choice between alternatives) and a product (a bundle of fields) —
and those operations obey the ordinary laws of high-school algebra. This lesson develops the calculus of
sums-of-products, the typing rules for constructors and case, and the beautiful compilation
problem hiding underneath a humble match: how a nested pattern becomes an efficient
decision tree, and how a compiler proves a match is exhaustive and free of dead branches.
Start from enum. An algebraic data type is nothing more exotic
than a sum of products: a choice among several constructors, each of which bundles zero or more
fields.
Why "algebraic"? Because if you read
The unit type
case: the typing rules
A constructor is an injection into a sum: it tags a value so the runtime — and the type system —
knows which alternative it came from. Case analysis is the only way back out. Here are the rules for a
binary sum
Notice that a single injection cannot, on its own, know the whole sum type — the
case, which must handle both tags and produce the same result type
Two demands make this the workhorse of type-safe programming. First, every constructor of the sum
must appear as an arm — miss one and the term does not type. Second, all arms must agree on the answer
type case has a single, predictable type no matter
which tag arrives at runtime. Exhaustiveness is not a lint suggestion; it is baked into the elimination
rule.
Real pattern matching goes deeper than one tag. You write a column of patterns, each possibly
nested — Some(None), Cons(0, rest), (true, _) — and the language
picks the first that fits. The compiler's job is to turn that declarative table into a decision
tree: a sequence of primitive tag-tests, each branch narrowing the possibilities, with no field
inspected twice and no redundant test. Consider matching a value of type
The naive reading tries each row top-to-bottom, re-testing the outer tag every time. The compiled
decision tree tests the outer tag once, then — only on the
Each internal node is a single switch on a constructor tag; each leaf is an action (a
right-hand side). This is exactly what maranget's algorithm — the one used in OCaml — produces:
it repeatedly picks a column, splits the pattern matrix by the constructors appearing there, and recurses,
yielding a tree whose every path corresponds to a distinct runtime shape. A good compiler even balances
the choice of which column to test first to keep the tree small.
The same matrix machinery answers the two questions a programmer most wants answered at compile time. Exhaustiveness: is there any value the patterns all miss? Redundancy: is any row unreachable because earlier rows already cover everything it could match? Both reduce to a single primitive — a usefulness check: given the rows so far, is there a value matched by a candidate row and by none above it?
Usefulness is decided by the same recursive constructor-splitting used to build the decision tree, which
is why a single algorithm gives you the compiled match, the "non-exhaustive: Some(None) not
matched" warning, and the "unreachable branch" warning all at once. When the checker reports a missing
case it can even reconstruct a witness — the smallest value that slips through — which is why
OCaml and Rust can print "you forgot Some(None)" rather than a vague complaint.
Here is the whole story in miniature: an ADT for a tiny expression language, encoded in TypeScript as a
tagged union (a sum of products), and an evaluator whose switch is a total, exhaustive
case. Because TypeScript narrows the union in each arm, forgetting a constructor is a
type error — the assertNever trick makes the exhaustiveness check explicit. Press
Run:
Delete the case "Lit" from evalExpr and the compiler rejects the program at
assertNever(e): e is no longer never, so the "impossible" branch is
reachable. That is exhaustiveness checking, running in your editor as you type.
Take the algebra seriously and something wonderful happens. If a type is a polynomial in its variable —
a binary tree is
The most dangerous pattern you can write is a catch-all where you meant to be exhaustive.
A final | _ => default arm silences the compiler forever: add a new constructor to your
datatype six months later and every match with a wildcard keeps compiling, silently routing the
new case to the wrong default. The exhaustiveness checker — your most valuable tool — has been switched
off precisely where you needed it. Prefer to enumerate the cases; let the compiler's
non-exhaustiveness error be your to-do list when the type grows.
A subtler trap: pattern order matters, and a too-general row above a specific one makes the specific one
redundant. | x => ... before | 0 => ... means the
0 case can never fire. Good compilers warn ("this match case is unused"), but the warning is
easy to ignore — and a redundant branch is often a real logic bug wearing a disguise.