Dive C1.06.3
Making illegal states unrepresentable
Choose a type whose values are exactly the domain's legal states — and the wrong value will not compile.
This is the payoff of the two builders. A domain has rules: a contact must reach a person, so it
needs an email, a phone, or both — never neither. Modelled naively as a record of two
option fields, the type admits four combinations, and one of them — both
None — breaks the rule. The type is wider than the domain, so every function that reads
it must guard against a value the domain forbids. Replace the record with a discriminated union
that lists only the three legal combinations, and the illegal state has no constructor: it cannot be written,
so no function needs to handle it. The compiler enforces the rule for free, once, at the type.
The naive model admits a bad state
The obvious model gives the contact two optional fields. It compiles, it reads clearly — and it is wrong: the
record allows { Email = None; Phone = None }, a contact that reaches no one. Two
option fields multiply to four combinations, and the domain only permits three.
type Contact = { Email: string option; Phone: string option } // all four of these compile — but the last one is illegal: { Email = Some "a@b.c"; Phone = None } // email only — ok { Email = None; Phone = Some "555" } // phone only — ok { Email = Some "a@b.c"; Phone = Some "555" } // both — ok { Email = None; Phone = None } // neither — illegal, yet builds
Nothing in the type stops the last line. The cost lands later: every function that takes a
Contact needs a branch for both fields being None —
a case the domain rules out, but the type allows.
The precise model removes it
List only the legal combinations as the cases of a union. There are three, so the type has three cases — and
no fourth. There is no way to write the both-None value, because no case constructs
it. The rule is now a property of the type, checked once by the compiler rather than re-checked by every
reader.
type ContactInfo = | EmailOnly of string | PhoneOnly of string | EmailAndPhone of string * string // three cases, three legal states — and no way to build "neither" let describe c = match c with | EmailOnly e -> sprintf "email: %s" e | PhoneOnly p -> sprintf "phone: %s" p | EmailAndPhone (e, p) -> sprintf "email: %s, phone: %s" e p describe (EmailOnly "a@b.c") // val it : string = "email: a@b.c"
The describe function handles three cases because there are three — no fallback
branch for an impossible state, and no warning, because nothing is missing. The illegal combination is gone
from the type, not guarded against in the code.
Four combinations, three legal
The grid is the four combinations of (email present?, phone present?). Toggle between the two models. Under
the naive record all four cells are constructible — the both-None cell is marked
illegal but still builds. Under ContactInfo that cell is struck out: it has no
constructor, so it is unrepresentable. The readout reports how many states each type admits.
(email?, phone?) · the model fixes which cells exist
Pick the type whose values are the domain's legal states, and the illegal one disappears — not guarded against in code, but absent from the type. The compiler keeps the rule, once.
The principle
Make the set of representable values match the set of legal values — no wider, no narrower.
In F#
A union of the three legal cases. The both-None state has no constructor, so it cannot be built.
Where this leads
Records and unions, used together, let the type carry the domain's rules. The next module fills these types
into collections — lists, arrays, sequences, maps, and sets — the containers that hold many
records and unions at once. The module after that meets Option and
Result, two unions from the standard library that turn absence and failure into
ordinary values.
References
Sources
- Microsoft — F# documentation — discriminated unions and domain modelling with the F# type system.
- The F# Language Specification — union type definitions and option types.
- The F# Software Foundation — the language's home and learning resources.
Related in this course
- C1.06.2 — Discriminated unions — the sum type this design rests on.
- C1.06 — Discriminated unions & records — the module these three dives complete.
- Elixir — Algebraic data types — the same modelling discipline on the BEAM.