Dive C2.04.2
From virtual dispatch to exhaustive match
One function reads every case — and the compiler counts the cases for you.
In C#, an operation on a hierarchy lives inside the shapes: each subclass overrides
Area() with its own formula, and the runtime dispatches to the right one. F# puts the
operation in one place: area is a single function that
matches every case of the union (verified: val area : Shape ->
float, area (Circle 1.0) is 3.141592654). The headline
difference is what the compiler does next. Because the union lists every case, the compiler checks that the
match covers them all; leave one out and it warns
FS0025, naming the case you missed. A C# switch over a class
hierarchy needs a catch-all _ arm and stays silent when a subclass is added — the gap
ships. This dive is that guarantee, and the trade it makes.
The operation in one function
An operation over the Shape union is one function. area
takes a Shape and matches each case, returning that case's
formula. The signature the compiler infers — Shape -> float — is the whole
contract: any shape in, a number out. There is no override scattered across subclasses; the three formulas sit
together where they can be read at once.
let area s = // val area : Shape -> float match s with | Circle r -> System.Math.PI * r * r | Rectangle (w, h) -> w * h | Triangle (b, h) -> 0.5 * b * h area (Circle 1.0) // val it : float = 3.141592654 area (Rectangle (2.0, 3.0)) // val it : float = 6.0 area (Triangle (4.0, 3.0)) // val it : float = 6.0
- match
- reads a union value by case, binding its payload —
Circle rbinds the radius,Rectangle (w, h)the pair. - one function
- the operation lives in
area, not inside each case; the union carries data, the function carries behaviour. - total
- a match that covers every case; the F# compiler checks for it and warns when it is missing.
- Shape -> float
- the inferred signature — the full set of inputs handled, stated once.
Drop a case, and the compiler warns
Here is the guarantee in action. Write a match that handles
Circle and Rectangle but forgets
Triangle, and the F# compiler does not stay quiet — it emits
FS0025 and names the value that is not covered. The warning is the literal output of
dotnet fsi, reproduced below.
let name s = match s with | Circle _ -> "circle" | Rectangle _ -> "rectangle" // warning FS0025: Incomplete pattern matches on this expression. // For example, the value 'Triangle (_, _)' may indicate a case not covered by the pattern(s).
The contrast with C# is the point. A switch over a class hierarchy must end in a
catch-all _ arm, because the set of subclasses is open and the compiler cannot prove
the cases are exhausted. That _ swallows any case the author did not list — including
a subclass added later — and nothing warns. The bug is silent precisely where the F# version is loud.
C# — a type switch, with a mandatory catch-all that hides the gap
string Name(Shape sh) => sh switch { CircleC => "circle", RectC => "rectangle", _ => "?" // required — and silently catches any new subclass }; Name(new RectC { W = 2, H = 3 }); // "rectangle"
F# — a match the compiler proves total, or warns about
let name s = match s with | Circle _ -> "circle" | Rectangle _ -> "rectangle" | Triangle _ -> "triangle" // add this and the warning clears; omit it and FS0025 names it
Toggle the arms of match s with
Turn each case's arm on or off. The checker mirrors what the F# compiler does: when all three cases are
handled the match is total and compiles clean; drop one and the verdict flips to the exact
FS0025 message, naming the first uncovered case. This is a compile-time guarantee,
shown — no value runs; the compiler reasons about the cases alone.
match over Shape · is it exhaustive?
| handled | verdict |
|---|---|
| Circle, Rectangle, Triangle | PASS — exhaustive, compiles clean |
| Circle, Rectangle (Triangle dropped) | FS0025 — value 'Triangle (_, _)' not covered |
| Circle (Rectangle, Triangle dropped) | FS0025 — value 'Rectangle (_, _)' not covered |
The compiler does not need to run a value to find the gap. It reads the cases off the union and the arms off the match, and reports the difference before the code ships.
The trade: cases versus operations
The two designs are not free of cost; they put the cost in different places. With a class hierarchy, adding
an operation means editing every subclass — a new method on each. With a union, adding an
operation is one new function, and the existing cases are untouched. The directions reverse for adding a
case: a new subclass in C# stands alone, while a new case in F# makes every
non-total match warn until it is handled. That last clause is the feature, not the
burden — the warnings are the list of every place the new case must be considered.
// a new operation over Shape is a new function — no case is edited: let perimeter s = // val perimeter : Shape -> float match s with | Circle r -> 2.0 * System.Math.PI * r | Rectangle (w, h) -> 2.0 * (w + h) | Triangle (b, h) -> b + h + sqrt (b * b + h * h) perimeter (Rectangle (2.0, 3.0)) // val it : float = 10.0
match that
is not total warns FS0025 until the new case is handled. The compiler turns "what did I
forget to update?" into a list it hands you at the keyboard.C# habit
Dispatch lives inside each subclass as an override; a switch needs a catch-all and never warns on a new subclass.
F# form
Dispatch is one match over the union; a missed case warns FS0025 by name, and a new operation is a new function.
Where this leads
One function, one match, an exhaustiveness check — that is the whole of dispatch for
a flat union. When the cases reference the type itself, the union becomes a tree, and the
match becomes recursive. That is the case the Visitor pattern was invented for in C#,
and the next dive collapses it.
References
Sources
- Microsoft — Pattern matching —
matchover a union and the incomplete-match warning. - Microsoft — Discriminated unions — why a closed set of cases lets the compiler check exhaustiveness.
- Microsoft — switch expression (C#) — type patterns and the catch-all arm this dive contrasts.
Related in this course
- C2.04.1 — From a class hierarchy to a DU — the union the match reads.
- C1.06 — Discriminated unions & records — matching and exhaustiveness in language depth.
- C2.01 — From classes to modules — behaviour in free functions, not on the type.