jonnifyfsharp

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.

C3.02.2 · part 1

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.

C3.02.2 · part 2

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

amount: none amount: Some paid = false paid = true Unpaid legal paid, no amount no DU value unpaid, amount no DU value · {:unpaid, 42} Paid 42.00M legal DU admits 2 of 4 record admits 4 of 4
unpaid · no amount · DU: Unpaid · record: { Paid = false; Amount = None }
Without JavaScript: the four states, by type
statediscriminated unionbool-soup record
unpaid · no amountUnpaid{ Paid = false; Amount = None }
paid · amountPaid 42.00M{ Paid = true; Amount = Some 42.00M }
unpaid · amountno value — unrepresentable{ Paid = false; Amount = Some 42.00M }
paid · no amountno 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.

C3.02.2 · part 3

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"
A 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

Related in this course