Dive C3.03.2
Exhaustiveness — the compiler checks every case
Drop a case in Elixir and you find out in production. Drop a case in F# and you find out at the keyboard.
This is the first genuine power F# adds to a habit an Elixir developer already has. In Elixir, a
case that omits a clause compiles fine; it raises CaseClauseError
only when an unhandled value reaches it at run time, and a test catches that only if the test supplies the value.
In F#, the type carries the full set of cases a discriminated union — or any finite type — can take, so the
compiler reads the match against that set and reports a missing case before the program
runs. The report is
warning FS0025, and it names a value that would slip through. The discipline an Elixir
developer keeps with tests, the F# compiler keeps for them.
The same omission, two outcomes
Take a type with three cases and a match that handles only two. In Elixir, the function compiles and runs; the third shape raises at run time, when and if it arrives. In F#, the omission is reported the moment the file is checked — the program does not need to run for the gap to show.
Elixir — compiles, fails at run time
# Elixir def area(:circle), do: 3.14159 * 4 def area(:square), do: 16 # area(:triangle) # ** (FunctionClauseError) no function clause matching — at run time
F# — reported at compile time (dotnet fsi 9.0)
type Shape = | Circle of float | Rectangle of float * float | Triangle of float * float let area s = match s with | Circle r -> 3.14159 * r * r | Rectangle (w, h) -> w * h // Triangle omitted
The compiler does not stay silent. It prints the exact line below — and it infers a value,
Triangle (_, _), that the match would not handle, so the gap is named, not merely
flagged:
warning FS0025: Incomplete pattern matches on this expression. For example, the value 'Triangle (_, _)' may indicate a case not covered by the pattern(s).
area (Circle 2.0) returns 12.566360. Its value is the timing:
the gap is named at the keyboard, not discovered in production. Most F# projects raise the stakes with
<TreatWarningsAsErrors> or --warnaserror:25, which turns the
missing case into a build failure.The witness, and any finite type
The value the compiler prints is a witness: an input the match does not cover. It is exact
for each shape. Omit the Error arm of a Result and the witness
is Error (_). Match a bool on true
alone and the witness is false — a bool is two cases, so
F# checks it the same way it checks a union. Exhaustiveness is not a union-only feature; it applies to any
type with a known, finite set of values.
// a Result matched only on Ok — verified FS0025 witness: let describe r = match r with | Ok v -> v // warning FS0025: … the value 'Error (_)' may indicate a case not covered // a bool matched only on true — verified FS0025 witness: let f b = match b with | true -> "yes" // warning FS0025: … the value 'false' may indicate a case not covered
Add the missing arm and the warning disappears. The complete match below compiles clean — no FS0025 — and
area (Triangle (3.0, 4.0)) returns 6.000000:
let area s = match s with | Circle r -> 3.14159 * r * r | Rectangle (w, h) -> w * h | Triangle (b, h) -> 0.5 * b * h // area (Triangle (3.0, 4.0)) -> 6.000000 — no FS0025
- FS0025
- the F# compiler's incomplete-match warning, emitted at compile time when a
matchomits a reachable case. - witness
- the value the compiler prints (
Triangle (_, _),Error (_),false) — an input the match does not handle. - finite type
- any type with a known set of cases — a union, a
bool, an enum — is checked for exhaustiveness. - warnaserror:25
- the switch that promotes FS0025 from a warning to a build-stopping error.
Toggle the cases, read the compiler
Below is the match over Shape from part 1. Toggle each arm
on or off. With all three covered, the verdict is "compiles — no FS0025". Drop one and the verdict becomes the
exact warning line the compiler emits, naming the witness value for the case you removed. The verdict is
computed by a pure function over the fixed set of three cases; the static table below holds every combination.
match s with · cover or omit each arm
| arms covered | verdict | witness named |
|---|---|---|
| Circle · Rectangle · Triangle | compiles, no FS0025 | — |
| Circle · Rectangle | FS0025 | Triangle (_, _) |
| Circle · Triangle | FS0025 | Rectangle (_, _) |
| Rectangle · Triangle | FS0025 | Circle (_) |
| one arm, or none | FS0025 | the first uncovered case |
The match either covers the type or it does not, and the compiler says which before the program runs. The missing case is named, so the fix is the line you add.
Why this matters coming from Elixir
In Elixir, an unmatched case raises CaseClauseError; an
unmatched function head raises FunctionClauseError. Both are real, both fire at run
time, and a test catches them only when the test happens to supply the unhandled value. The everyday risk is
adding a new variant — a fourth atom, a new tagged tuple — and forgetting one of the places that matched on the
old set.
F# moves that check to compile time. Add a fourth case to a union and every match on
it that you did not extend lights up FS0025, each naming the case it now misses. The work an Elixir developer
does by writing a test per clause, the F# compiler does by reading the type.
Elixir
a non-exhaustive case raises CaseClauseError at run time — caught only when the unhandled value arrives.
F#
a non-exhaustive match reports FS0025 at compile time, naming the missing case — the check moves from the test suite to the compiler.
Where this leads
Exhaustiveness checks the cases a type already has. The next dive extends the pattern language itself: active patterns let a programmer add a case of their own — a named test the matcher treats like a built-in — so the match arms can describe logic the type does not encode.
References
Sources
- Microsoft — F# documentation — pattern matching and the incomplete-match warning (FS0025).
- FSharp.Core API reference — the
Resulttype matched against in the witness examples. - Microsoft — .NET documentation — compiler options, including warnings-as-errors.
Related in this course
- C3.03.3 — Active patterns — extending the pattern language with your own cases.
- Elixir — Equations & pattern matching — matching as solving, on the BEAM.
- C1.05 — Pattern matching & active patterns — the same checking in language depth.