jonnifyfsharp

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.

C3.03.2 · part 1

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).
FS0025 is a warning, not an error — the code still compiles and 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.
C3.03.2 · part 2

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 match omits 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

match (s: Shape) with | Circle r covered | Rectangle (w, h) covered | Triangle (b, h) covered COMPILER compiles · all three cases covered · no FS0025
dotnet fsi says compiles · the match is exhaustive over Shape · no warning
Without JavaScript: every coverage, and what the compiler reports
arms coveredverdictwitness named
Circle · Rectangle · Trianglecompiles, no FS0025—
Circle · RectangleFS0025Triangle (_, _)
Circle · TriangleFS0025Rectangle (_, _)
Rectangle · TriangleFS0025Circle (_)
one arm, or noneFS0025the 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.

C3.03.2 · part 3

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

Related in this course