Imagine a long chain of questions about a single value: "Is it empty? No. Does it have a name? Yes, but is the name blank? And is it a circle or a rectangle? And if it's a circle, what's its radius?" Written out as if / else if / else if ..., this quickly turns into a tangled ladder that's hard to read and dangerously easy to get wrong — miss one rung and your program quietly does the wrong thing.
Pattern matching is the cleanup. It's a supercharged switch statement that looks at the shape and contents of a value and routes it to exactly one matching branch — clearly, all in one place.
How it works
You hand one value to a match ... with expression, which lists several cases, each describing a shape the value might have. The value is checked against the cases from top to bottom, and the first one that fits wins — its branch runs, and the rest are skipped.
The clever part is that a case can also unpack the value as it matches, lifting the pieces you care about out into named variables. So matching and grabbing-the-contents happen in a single, tidy step.
- CaseOne branch of a match; the compiler checks every case is covered.
The classic place this clicks is the maybe-value — an Option that's either Some x (a value is present) or None (nothing's there). Matching makes you face both possibilities head-on:
let describe (opt: int option) =
match opt with
| Some x -> sprintf "Got the number %d" x
| None -> "Got nothing at all"
describe (Some 42) // "Got the number 42"
describe None // "Got nothing at all"
Look at Some x: it both checks "is there a value?" and binds that value to x so the branch can use it. No null checks, no peeking inside — the shape and the contents come out together.
This is where exhaustiveness earns its keep. If you'd written only the Some x case and forgotten None, the F# compiler would warn you: "Incomplete pattern match — what about None?" The bug that would've crashed at runtime gets caught before you even run the program.
- matchBranches on the shape of the value; the compiler checks every possible case has a branch.
- Unhandled caseTriangle has no branch — exhaustiveness checking flags the gap at compile time, before it can crash.
Matching on your own shapes
Pattern matching really comes alive next to a discriminated union — a type that says "a value of this is one of these cases." Here's a Shape that's either a circle or a rectangle, each carrying its own measurements:
type Shape =
| Circle of radius: float
| Rectangle of width: float * height: float
let area shape =
match shape with
| Circle r -> 3.14159 * r * r
| Rectangle (w, h) -> w * h
area (Circle 2.0) // 12.566...
area (Rectangle (3.0, 4.0)) // 12.0
Each case names the shape and pulls out exactly the numbers that shape carries — r for the circle, w and h for the rectangle. Add a third shape like Triangle later, and the compiler will point right at this match and remind you it now needs a triangle case too.
Sometimes a case needs an extra condition — not just "is it a rectangle?" but "is it a rectangle that happens to be a square?" That's what a guard is for, using the when keyword:
let label shape =
match shape with
| Rectangle (w, h) when w = h -> "A square!"
| Rectangle _ -> "A rectangle"
| Circle _ -> "A circle"
The when w = h clause runs only if the value already matched the shape and passes the test. The _ you see is a wildcard — it means "there's a value here, but I don't care what it is."
Why it's great
Compared to an if / else if ladder, a match reads like a labelled menu: every case a value could be is right there, side by side, instead of buried in nested branches. You spend less effort following the logic and more confidence that you've covered everything.
And that confidence isn't just a feeling — it's enforced. Because the compiler checks for exhaustiveness, "oops, I forgot to handle that case" stops being a 2 a.m. production mystery and becomes a friendly squiggle in your editor.
Pattern matching and discriminated unions are a team. The union says "this data is exactly one of these cases," and matching is how you safely take it apart. Together they let you model your problem honestly — and then have the compiler make sure you've dealt with every possibility.
So next time you feel an if / else if ladder growing rung by rung, reach for match ... with instead. Branch on the shape, unpack the contents in the same line, lean on guards for the fiddly cases, and let exhaustiveness watch your back.