Dive C3.02.2
Making illegal states unrepresentable
The strongest use of a type: a wrong state with no value to hold it.
In Elixir, a payment is a tagged tuple — {:paid, amount} — and "unpaid" is
an atom, :unpaid. The tag carries the meaning, and the code agrees to honour it. But
{:unpaid, 42} — an unpaid payment that owes 42 — is a perfectly legal term; nothing in
the language stops it. F# moves the rule into the type. A discriminated union ties the amount to the paid case,
so the unpaid case has no slot for one. The nonsense state is not caught at run time — it has no value, and
cannot be written.
Shape by convention, in Elixir
The Elixir model is idiomatic and clear: the tag says what the tuple means, and the reader matches on it. The cost is that the language does not know the rule. An unpaid payment with an amount is as legal a term as any other — a typo or a wrong pipeline stage can build it, and only a run-time match will reject it.
# Elixir — the tag says what the tuple means; a bug can still build nonsense {:paid, 42.00} # a payment of 42 :unpaid # no charge {:unpaid, 42.00} # nothing stops this — "unpaid but owes 42"
The third line is meaningless, yet legal. The discipline that keeps it from appearing lives in every function that constructs a payment — never in the data itself.
The amount belongs to the case, in F#
A discriminated union names the cases and what each one carries. The decimal lives
inside Paid; Unpaid carries nothing. There is no
Payment that is both unpaid and holds an amount — the type has no room for it. FSI
echoes the declaration and a value built from it.
type Payment = | Unpaid | Paid of decimal // type Payment = // | Unpaid // | Paid of decimal Paid 42.00M // val it: Payment = Paid 42.00M Unpaid // val it: Payment = Unpaid
A reader handles both cases with a match, and reaches the amount only on the branch
where it exists. There is no path to an amount on the Unpaid side, because that side
has none.
let describe p = match p with | Unpaid -> "no charge" | Paid amt -> sprintf "charged %M" amt // val describe: p: Payment -> string describe (Paid 42.00M) // val it: string = "charged 42.00" describe Unpaid // val it: string = "no charge"
- Paid of decimal
- the case and its payload; an amount cannot exist except as part of a
Paid. - Unpaid
- a case with no payload — the typed "no charge", with no amount slot to fill wrongly.
- vs. {:unpaid, _}
- Elixir's tag is a convention any term can violate; an F# case is a shape the compiler will not let you build wrong.
Build a state — is it representable?
Set the two facts a payment could carry: whether it is paid, and whether an amount is present. The diagram
shows the four combinations. The discriminated union admits only two — the
legal ones; the other two have no value to build. The
bool-soup record admits all four, including the illegal
pair that is Elixir's {:unpaid, 42} reborn in F#.
the four combinations · which the type admits
| state | discriminated union | bool-soup record |
|---|---|---|
| unpaid · no amount | Unpaid | { Paid = false; Amount = None } |
| paid · amount | Paid 42.00M | { Paid = true; Amount = Some 42.00M } |
| unpaid · amount | no value — unrepresentable | { Paid = false; Amount = Some 42.00M } |
| paid · no amount | no value — unrepresentable | { Paid = true; Amount = None } |
Two booleans give four combinations; the domain has two. The discriminated union has exactly two, and the wrong pair is not a caught error — it is a value that was never possible to write.
The anti-pattern F# also allows
A type system does not make good modelling automatic. The same domain modelled with a flag and an optional
amount re-admits the nonsense — { Paid = false; Amount = Some 42.00M } is the F# twin
of {:unpaid, 42}, and it type-checks.
type BadPayment = { Paid: bool; Amount: decimal option } let nonsense = { Paid = false; Amount = Some 42.00M } // val nonsense: BadPayment = { Paid = false // Amount = Some 42.00M } — "unpaid but owes 42"
bool and a decimal option give
2 × 2 = 4 states where the domain has two. Making illegal states unrepresentable is a
modelling choice: pick the type whose value set is the domain's — the discriminated union — not a
superset you must then guard.The exhaustiveness backstop
When the type is a DU, the compiler checks the match covers every case. Forget one
and it warns, naming the case you missed — the same FSI behaviour an Elixir developer would otherwise meet as
a FunctionClauseError in production.
let bad p = match p with | Paid amt -> amt // warning FS0025: Incomplete pattern matches on this expression. // For example, the value 'Unpaid' may indicate a case not covered by the pattern(s).
Elixir
A missing clause is a FunctionClauseError raised when the uncovered value arrives — found at run time, in production.
F#
FS0025 at the keyboard — the compiler names the case you forgot before the program runs.
Where this leads
The discriminated union turns a class of run-time shape bug into a thing that cannot be built, and the exhaustive match turns a missing clause into a compiler warning. That is a real gain — and it is not free. The next dive weighs it honestly against what Elixir's dynamism gave: hot code reload, metaprogramming, and less ceremony while a design is still moving.
References
Sources
- Microsoft — F# documentation — discriminated unions, records, and pattern-match completeness.
- F# Core Library reference —
Optionand the core types used in the model. - Microsoft — .NET documentation —
decimalon the CLR and how F# values map onto it.
Related in this course
- C3.02.3 — The trade — what this guarantee costs, weighed against Elixir's dynamism.
- C1.06 — Discriminated unions & records — the same idea, in language depth.
- Elixir F2.07 — Algebraic data types — tagged tuples and atoms, the model F# types.