jonnifyfsharp

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.

C1.06.3 · part 1

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.

C1.06.3 · part 2

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.

C1.06.3 · part 3

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

phone = Some phone = None email = Some email = None email & phone EmailAndPhone email only EmailOnly phone only PhoneOnly neither unrepresentable
type ContactInfo · admits 3 states · the both-None cell has no constructor — unrepresentable

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

Related in this course