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.
Here's a shop's Payment in F#. Every payment is exactly one of three shapes, and each shape carries its own data:
type Payment =
| Card of last4: string
| Cash of amount: decimal
| Voucher of code: string
The describe function below matches on a payment, with one twist: the second case adds a when test, so big cash payments get their own branch. Step through describe (Cash 250m) and predict which line runs. Then switch the payment to watch other values find their way down.
Two things to take away from that. Order matters: the first case that fits wins, so the specific Cash ... when amount > 100m line has to sit above the plain Cash amount line. Swap them and the plain line catches every cash payment first; the when line can never run, and F# warns you that "this rule will never be matched."
And matching unpacks: Card last4 both checks "is it a card?" and hands you the digits in last4. No casting, no reaching into fields — the shape and its contents come out together.
You put the plain "| Cash amount ->" line above the "| Cash amount when amount > 100m ->" line. A Cash 500m payment arrives. What happens?
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 hands you that value as x so the branch can use it. No null checks, no peeking inside.
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 matches on this expression. For example, the value 'None' may indicate a case not covered." The bug that would've failed at run time gets caught before you even run the program.
The compiler's checklist
Exhaustiveness really pays off when a type grows. Because the compiler knows every case a Payment can be, it checks every match against that full list — in every file, every time you build.
Say three functions match on Payment: one prints receipts, one works out fees, one handles refunds. Then the business asks for crypto. Step through adding the new case, and predict what happens when you build. Then flip fee.fs to a version that ends in a catch-all | _ -> and run it again.
A catch-all turns the checklist off. A final | _ -> ... matches everything, so the compiler considers that match complete forever — including for cases you haven't invented yet. Use _ when you truly mean "every other case, now and in future"; otherwise list the cases and let the compiler find them for you. Also note that a missing case is a warning (FS0025) by default, and an unhandled value crashes with a MatchFailureException at run time. Many teams make it a hard error with <WarningsAsErrors>FS0025</WarningsAsErrors> in the project file.
Matching on your own shapes
A type like Payment is called a discriminated union — a type that says "a value of this is one of these cases." Cases can carry several pieces of data, and a match unpacks them all at once. Here's a Shape that's either a circle or a rectangle:
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.
You've already met the guard: the when keyword that adds an extra condition to a case. It works with any unpacked values:
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 _ here is a wildcard inside a case — "there's a value here, but I don't care what it is" — which is perfectly safe. It's the bare | _ -> covering whole cases that hides new ones.
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.
Your team adds a Refund case to a union. The compiler flags four of your five matches, you fix those, and ship. In production, refunds go down the wrong path in the fifth. What's the most likely reason?
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.