jonnifyfsharp

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.

C2.04.2 · part 1

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 r binds 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.
C2.04.2 · part 2

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?

the three cases of Shape Circle ✓ Rectangle ✓ PASS · the match is exhaustive all three cases handled — compiles clean no catch-all needed
PASS · all three cases handled — the match is total and compiles clean.
Without JavaScript: which cases must be handled, and the compiler's verdict
handledverdict
Circle, Rectangle, TrianglePASS — 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.

C2.04.2 · part 3

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
Adding a case to the union flips the trade the other way: every 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

Related in this course