Dive C2.04.1
From a class hierarchy to a DU
Every case in one declaration, so the whole set is there for the compiler to check.
In C#, "one of several shapes" is an abstract base class with a subclass per
shape — CircleC : Shape, RectC : Shape — and any file is free
to add another. The set of shapes is open: it is never written down in one place, so no tool can be
sure it has the full list. F# writes the same domain as one discriminated union:
type Shape = Circle of float | Rectangle of float * float | Triangle of float * float.
Every case sits in that single declaration — the set is closed. A value is built by naming a case and
its payload (Circle 1.0), and because the type lists every case, the compiler can later
check that each operation handles them all. This dive is the data half of the shift; the dispatch half is the
next one.
One type, a case per shape
A discriminated union is a single type whose value is exactly one of a fixed list of cases. Each
case has a name and, optionally, a payload. The Shape union below has three cases: a
Circle carrying one float (its radius), and a
Rectangle and a Triangle each carrying two. The whole set of
shapes is the three lines of the declaration — there is no fourth shape hiding in another file.
type Shape = | Circle of float | Rectangle of float * float | Triangle of float * float // a closed set of cases
A value is constructed by naming the case and supplying its payload — no new, no
constructor body. Circle 1.0 is a Shape; so is
Rectangle (2.0, 3.0). Each is the whole value, tagged with which case it is, ready to
be read back by a match.
let c = Circle 1.0 // val c : Shape = Circle 1.0 let r = Rectangle (2.0, 3.0) // val r : Shape = Rectangle (2.0, 3.0) let t = Triangle (4.0, 3.0) // val t : Shape = Triangle (4.0, 3.0)
- case
- one named alternative of the union —
Circle,Rectangle,Triangle. A value is exactly one of them. - of
- introduces a case's payload:
Circle of floatcarries one number,Rectangle of float * floatcarries a pair. - closed set
- the cases are the whole type; adding one is an edit to this declaration, which the compiler then propagates.
- vs. subclass
- a subclass is open — declared anywhere, never enumerated; a case is closed — listed here, and counted.
The C# the reader knows, beside the F#
In C#, the same three shapes are an abstract base class and three subclasses. The shapes are correct, but the
set of them is spread across however many files declare a : Shape, and nothing
collects them. The F# union puts the identical information on three lines, in one place, where the compiler
has all of it at once.
C# — an abstract base class and a subclass per shape
abstract class Shape { public abstract double Area(); } class CircleC : Shape { public double R; public override double Area() => Math.PI*R*R; } class RectC : Shape { public double W, H; public override double Area() => W*H; } // build one — and any other file may add a fourth subclass: Shape sh = new RectC { W = 2, H = 3 }; // sh.Area() == 6
F# — one union, every case in the declaration
type Shape = | Circle of float | Rectangle of float * float | Triangle of float * float // build one — the set of shapes is exactly the three cases above: let sh = Rectangle (2.0, 3.0) // val sh : Shape = Rectangle (2.0, 3.0)
A case can name its fields, which reads better than a bare tuple when the payload is more than one anonymous
number. The Payment union below tags each case the way a domain would: a
Card carries a last4 string, a Cheque
carries a number. The describe function reads them back, and
the FSI echoes are exact.
type Payment = | Cash | Card of last4: string | Cheque of number: int let describe p = // val describe : Payment -> string match p with | Cash -> "cash" | Card l4 -> "card ****" + l4 | Cheque n -> sprintf "cheque #%d" n describe (Card "4242") // val it : string = "card ****4242"
Build a Shape value
Pick a case and its payload. The diagram constructs the value the F# way — case name, then payload — and
highlights the one match arm in area that this case selects.
The readout shows the constructed Shape and the arm's exact result, the value
dotnet fsi prints. Every case has exactly one arm, and one arm fires.
a Shape value · and the match arm it selects
| case | constructed value | match arm | area |
|---|---|---|---|
| Circle | Circle 1.0 | | Circle r | 3.141592654 |
| Rectangle | Rectangle (2.0, 3.0) | | Rectangle (w, h) | 6.0 |
| Triangle | Triangle (4.0, 3.0) | | Triangle (b, h) | 6.0 |
Constructing a value names a case; reading it picks an arm. Because the cases live in one declaration, there is always exactly one arm for the value, and the compiler can count them.
Why the location matters
The gain is not a shorter way to spell a class. It is that the set of cases now has a single home. With a
base class, the shapes are wherever their subclasses are declared, and a question like "have all the shapes
been handled here?" has no answer the compiler can give — the list is open. With a union, the list is the
declaration, so that question becomes a check the compiler runs on every match. The
next dive is exactly that check.
match the union, which is the move dive C2.04.2 takes up: one function per
operation, rather than one override per subclass.C# habit
An abstract class with a subclass per shape — the set open, declared across files, never enumerated.
F# form
One type Shape = Circle | Rectangle | Triangle — the set closed, in one declaration, ready to be checked.
Where this leads
The union holds the shapes; the next dive adds the operations. In C# each operation is an
override inside each subclass; in F# each operation is one function that
matches every case — and the compiler checks that the match is total. The dive after
takes the recursive case, where the Visitor pattern lived.
References
Sources
- Microsoft — Discriminated unions — case declarations, payloads, and named fields.
- FSharp.Core API reference —
sprintfand the core library these examples use. - Microsoft — Inheritance (C#) — abstract classes and subclasses, the open hierarchy this dive translates.
Related in this course
- C2.04.2 — From virtual dispatch to exhaustive match — the operations the union carries.
- C1.06 — Discriminated unions & records — the union type in language depth.
- C2.01 — From classes to modules — data beside behaviour, the move this dive continues.