Pattern matching, the only control flow
Take a breath. If you have Elixir in your background, you already think this way; the ML version is just stricter and total.
type shape =
| Circle of float
| Rectangle of float * float
| Triangle of float * float * float
let area s =
match s with
| Circle r -> 3.14159 *. r *. r
| Rectangle (w, h) -> w *. h
| Triangle (a, b, c) ->
let s = (a +. b +. c) /. 2.0 in
sqrt (s *. (s -. a) *. (s -. b) *. (s -. c))
Two things to internalize.
Exhaustiveness
First, match is exhaustive by default and the compiler will warn (configure it to error) if you miss a case. This is the difference between a switch in C and a match in ML: the compiler has counted your portals, and if you’ve left one un-entered it tells you which one.
In systems code, this means refactoring a tagged union — adding a new state — produces a complete enumeration of every site that needs updating. No grep. No “did I get them all.” The compiler is your refactor.
Recall the cardinality story from the previous chapter. A sum with three variants has cardinality 3. A match expression on it should handle 3 cases. The compiler is doing arithmetic on your code: it’s counting cases against variants. If the numbers don’t agree, it tells you.
This generalizes. If you add a fourth variant later, every match expression on the type becomes incomplete. The compiler emits a list. You walk the list, decide for each case what the new variant should do, and move on. This is what refactoring should be: not “find every usage of the function and update it,” which requires correct grep, but “the compiler told me exactly what I need to fix and now I fix it.”
Nested patterns
Second, patterns nest:
match (x, y) with
| (Some a, Some b) -> ...
| (Some a, None ) -> ...
| (None, Some b) -> ...
| (None, None ) -> ...
You can read a pattern as a shape you’re asserting the value has; if the shape matches, the bindings are introduced. The pair pattern matches if both sides match. The Some a pattern matches if the option is Some and binds a to its contents. Compose these arbitrarily; matches can be as deep as your data structure.
This is the same intuition as Elixir’s {:ok, value} matches, stretched across the whole language as the primary mechanism for case analysis. There’s no special syntactic distinction between “small destructuring” and “big dispatch” — it’s all pattern matching, all the way down.
A consequence: pattern matching is the only control flow you actually need. if/else exists in ML — it’s if e1 then e2 else e3 — but it’s a special case of a match on a boolean:
match b with
| true -> e2
| false -> e3
The moment you have more than two branches, or any structure to inspect, you reach for match. The if form is for when the boolean is opaque (a comparison result, a returned flag) and you don’t want the ceremony.
Coming from Lisp
You’re used to cond and case and match (in Racket, Clojure, etc.) being convenient. Here it’s not convenient — it’s the mechanism. There’s no if/elseif/else chain idiom. There’s no visitor pattern. Get comfortable reaching for match first.
The Lisp tradition has macros, which give you the freedom to invent control structures. ML doesn’t have macros (at least, not in its small canonical form). It gets that expressive power instead from algebraic data types and exhaustive matching — once you have those, you can express most of what a Lisper would reach for a macro for, but with the compiler checking your work.
Some Lisp idioms translate directly:
| Lisp idiom | ML equivalent |
|---|---|
(cond ...) | `match v with |
(case k ...) | `match k with |
| Tagged data via cons cells | Algebraic data types |
| Quote-and-eval for control flow | First-class functions and effect handlers |
(if ...) for two-way | if then else |
The piece that doesn’t translate: dynamic typing. ML’s matches resolve at compile time, and you cannot match on values whose types aren’t known statically. This is a genuine constraint, and people coming from Lisp sometimes find it claustrophobic at first. The payoff is that the compiler is doing more for you. You give up the flexibility; you get back certainty.
Coming from object-oriented languages
Subtype polymorphism is not how ML dispatches on cases. There’s no virtual method table being consulted. The variant tag is a small integer, the match compiles to a jump table or decision tree, and the whole thing is open to the optimizer. When you write a hot loop over a sum type, the dispatch is roughly free — comparable to a switch over an enum in C, often better because the compiler knows the variants are closed.
This is the inversion you’ll feel most. In OO, adding a new case is easy (subclass) and adding a new operation is hard (touch every class). In ML, adding a new case is hard (touch every match) and adding a new operation is easy (write a new function with a new match). The two languages have made opposite choices about which axis is easy to extend.
For systems work — protocol parsers, schedulers, control planes, compilers — the cases are usually closed by external specification (HTTP methods don’t change weekly) and the operations multiply. ML’s choice fits this shape. We’ll come back to this in chapter 4.
What this chapter committed to
Pattern matching as the primary control-flow mechanism. Exhaustiveness as a compile-time check, with refactoring leverage. Nesting and composition. Some translations from Lisp and OO idioms.
The next chapter takes the type-as-proposition idea seriously and shows what it does for engineering practice.