Making Illegal States Unrepresentable
A model where status = "paid" with paidAt = null cannot be written at all. Powerful where an invariant justifies it — and easy to overdo on a model that has no such invariant.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.
This combination of values is always a bug. Can I shape the type so it cannot be written, and is that worth what the shape costs?
A dashboard divides revenue by the number of days since payment and crashes on orders where status = 'paid' and paidAt is null. Thirty such rows exist. Nobody can explain them, and the fix under discussion is a null check in the dashboard.
Add a null check where it crashed. The data has a few odd rows, the dashboard should be robust, and defensive code is good practice.
The null check makes the symptom disappear and leaves thirty rows asserting something false, so every future consumer meets the same surprise and adds its own check (Swallowed Errors).
- The null check makes the symptom disappear and leaves thirty rows asserting something false, so every future consumer meets the same surprise and adds its own check (Swallowed Errors).
- Defensive checks accumulate at every consumer, and each one has to invent a policy — skip the row, treat it as unpaid, use the created date — so the system develops several incompatible interpretations of the same broken data.
- The real question, which the null check avoids, is which code path wrote a paid order without a timestamp. Without that answer the thirty rows become three hundred.
- As states are added, the number of field combinations that must be checked grows multiplicatively, and the checking is spread across every consumer instead of being done once (Boolean Flag Explosion).
What limits the solution, and what must never stop being true
This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.
- The API returns a flat JSON object and external clients depend on its shape, so the wire format cannot change quickly.
- The ORM maps columns to nullable fields and has no support for sum types.
- The team writes TypeScript on the server and the type is erased at runtime, so anything arriving by JSON parse bypasses whatever the types claim.
- If an order is paid, the payment timestamp and the capture id both exist.
- A value that reaches the domain has been through a check that establishes its shape; a value that has not been checked is a different type.
- The set of representable values equals the set of legal values, or the difference is explicitly guarded.
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- The type owns which combinations exist. If the type permits an illegal one, something at runtime must own rejecting it, and that owner must be named (Enforcing Invariants).
- The parsing boundary owns turning untrusted input into a value of the domain type, and is the one place a runtime check is unavoidable (Parse, Do Not Validate).
- Consumers own using the value, not validating it. A consumer that checks for an impossible case is telling you the boundary is not doing its job.
- Somebody owns the thirty existing rows — deciding what they mean is a data question, not a type question (Data Migration).
- The boundary is where untrusted data becomes a domain value. Inside it the invariant holds by construction; outside it nothing is assumed.
- The wire format is deliberately outside. JSON has no sum types, so the API keeps its flat shape and the mapping into the modelled type happens at the edge (Boundary Adapters).
- The persistence layer is also outside. Nullable columns are how relational databases work, and the model does not have to mirror them (Schema Leakage).
The combination that cannot be written
The illegal state is not "null". It is the *combination* — a status claiming a payment happened alongside no evidence that it did. The fix is not to check for the combination but to make the two facts one fact.
Read the second half carefully: exhaustive matching is the part that keeps paying. Adding a sixth state to this enum produces a compile error at every place in the codebase that consumes an order, which is a list of everything you must think about, generated for free.
1// representable and illegal:2// struct Order { status: Status, paid_at: Option<DateTime>, capture_id: Option<CaptureId> }3// -> status = Paid with paid_at = None compiles, and 30 rows did exactly that4 5enum Payment {6 Unpaid,7 Paid { at: DateTime, capture: CaptureId },8 Refunded{ at: DateTime, capture: CaptureId, refund: RefundId },9}10 11fn days_since_payment(p: &Payment, now: DateTime) -> Option<i64> {12 match p {13 Payment::Unpaid => None,14 Payment::Paid { at, .. } => Some((now - *at).num_days()),15 Payment::Refunded { at, .. } => Some((now - *at).num_days()),16 }17 // add a fourth variant and this fails to compile,18 // here and at every other consumer. That list is the value.19}There is no null check in days_since_payment and there cannot be one, because there is no value of Payment that means "paid, timestamp missing". The dashboard crash is not fixed; it is unwriteable. The cost is visible in the same snippet: the wire format is still flat JSON, so somewhere at the edge there is a parser mapping three nullable fields onto this enum and rejecting the combinations that do not fit — and that parser is now the only place the check exists.
The same design where the compiler will not help
Most teams are not writing Rust, and the design is still available — with one crucial difference that is routinely glossed over. In an erased type system the union is a compile-time fiction: JSON.parse returns whatever the payload contained, and a cast will happily label it as a legal state.
That does not make the design worthless. It makes the parse boundary load-bearing rather than decorative, and it means the guarantee is only as good as the test proving every entry point goes through it.
type Payment =
| { kind: 'unpaid' }
| { kind: 'paid'; at: Date; capture: CaptureId }
// looks safe, is not:
const order = JSON.parse(body) as Order
// ^^ the 30 bad rows arrive here
// wearing a legal type
if (order.payment.kind === 'paid') {
daysSince(order.payment.at) // undefined at runtime
}function parsePayment(raw: unknown): Result<Payment, Invalid> {
if (raw.status === 'unpaid') return Ok({ kind: 'unpaid' })
if (raw.status === 'paid') {
if (!raw.paid_at || !raw.capture_id)
return Err({ code: 'paid-without-evidence', raw })
return Ok({ kind: 'paid', at: new Date(raw.paid_at), capture: raw.capture_id })
}
return Err({ code: 'unknown-status', raw })
}
// every entry point calls this; a test asserts that.
// downstream code never checks again.The difference is not the type — both files declare the same union. It is that in the second version there is exactly one function that can produce a Payment, so the invariant is established once and every consumer inherits it. In the first version the type is a comment that the compiler happens to read. The thirty rows are also now visible as paid-without-evidence errors at the boundary rather than as a crash in a dashboard three systems away, which is what turns a mystery into a fixable bug (Error Boundaries).
How precise should the model be?
The failure mode of this technique is not under-use, it is maximalism. A model that encodes every invariant is beautiful until an invariant turns out to be conditional, at which point loosening it is a redesign that touches every consumer instead of a nullable field that touches none.
The middle row is the recommendation and it is not a compromise — it is the observation that only some invariants are worth structural enforcement, and choosing which is the actual engineering judgement here.
| Option | Simplicity | Flexibility | Testability | Migration cost | Operational | Note |
|---|---|---|---|---|---|---|
| Flat fields, checks at consumers | Status plus nullable columns, mirrored straight into the model. Every consumer decides for itself what an impossible combination means, and they decide differently. Cheap to write and the source of the dashboard crash. | |||||
| Precise about the invariants that are real | Payment state carries its evidence; the delivery address stays a nullable field because it genuinely may be unknown. Consumers check nothing about payment and check the address once. The recommendation. | |||||
| Maximally precise | Every optional field modelled as a variant, every combination enumerated. Correct today, and the first requirement that makes an invariant conditional forces a change at every consumer (Premature Abstraction). |
caveat The scores are for a system whose invariants are stable. The axis that actually decides is not on the table: how confident you are that the invariant is permanent. "Paid implies a timestamp" has been true since the first commit and will be true forever, so encoding it structurally is safe. "Every shipped order has a tracking number" feels equally solid right up until a courier partner is onboarded who does not issue them — and at that moment the maximally-precise model requires a migration, a model change and an edit at every consumer, while the loose model requires a nullable column. Encode what is definitional, not what is merely currently true.
How to build it
Most important first.
- Group the fields that only make sense together into the state that implies them, so
paidAtandcaptureIdlive insidePaidand cannot exist without it (Boolean Flag Explosion shows the enum version). - Use the strongest mechanism the language offers: a sum type with exhaustive matching where available, a discriminated union where the language has structural types, a private constructor plus a factory where it does not.
- Parse once at the boundary and never re-check. If the domain type can only be produced by a successful parse, every consumer downstream is safe by construction (Parse, Do Not Validate).
- Apply it where an invariant justifies it.
PaidimpliespaidAtis a real business rule; a wrapper around every optional field is not, and it makes the model longer without making it safer (Over-Design and Under-Design). - Keep the escape hatch explicit. Deserialization, ORM hydration and test fixtures all construct values without going through the factory; in an erased type system these are the holes, and each one needs a test rather than a promise (Optional Values and Absence).
- Fix the writer before fixing the readers. The thirty rows were produced by a code path, and the type change is what makes that path fail to compile.
What the next change costs
The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.
- Before: every new consumer of orders costs a defensive check and a decision about what to do with impossible data, forever. The cost is small each time and never stops.
- After: a new consumer costs nothing. It matches on the state and the compiler lists the cases it must handle.
- Adding a state: one variant, and every exhaustive match in the codebase fails to compile until it is handled. That is the most valuable property of this design and it is worth more than the null safety.
- The change that becomes more expensive: a state whose implied data becomes optional. "Paid, but the timestamp is unknown for orders migrated from the old system" is a field change under the loose model and a model change under the precise one — which is exactly the case §74 warns about, and the reason not to make every model maximally precise.
- A precise model needs mapping code at every boundary — wire, database, test fixtures — and that code is boring, plentiful and a real source of bugs of its own.
- It is less flexible by design. Every loosening of an invariant is a model change rather than a field change, which is the cost of the guarantee.
- In languages without sum types the same design is verbose enough that teams abandon it halfway, leaving a model that is precise in some places and not others — the worst outcome, because readers cannot tell which is which.
What can go wrong
- The model becomes precise and the deserializer bypasses it, so the illegal state exists at runtime inside a type that says it cannot. This is the characteristic failure in erased type systems and it is completely silent.
- Every field is modelled to maximum precision, and the type is now so specific that a routine requirement change — a paid order whose timestamp is unknown after a migration — requires reshaping the model rather than setting a field (Premature Abstraction).
- The mapping layer between the flat wire shape and the modelled type becomes the largest and least-tested part of the module (What an Abstraction Costs).
- The mitigation fails too: a runtime validator is added to close the deserialization hole, and it drifts from the type definition because the two are written separately — so the code claims one thing and checks another (Duplicate Knowledge).
- Consumers depend on the domain type rather than on the row shape, which decouples them from every future schema change (Dependency Direction).
- The parse boundary depends on both the wire shape and the domain shape, deliberately, so nothing else has to.
- In an erased type system, the guarantee depends on the discipline of routing every input through the parse boundary — a dependency on process, which is exactly why it needs a test.
- "Make every state unrepresentable." No. Model the invariants you actually have. A model precise about things that are not always true is worse than a loose one, because it forces a redesign the first time reality disagrees (Over-Design and Under-Design).
- "This needs a fancy language." The technique is stronger in Rust or Haskell and entirely available in TypeScript, Kotlin, Swift and even Java with sealed types. What changes is the strength of the guarantee, not the availability of the design.
- "The types guarantee it." In an erased type system they guarantee nothing at runtime. A JSON parse, an ORM hydration or a cast walks straight past them, which is why the parse boundary and its tests are the real mechanism (Parse, Do Not Validate).
- "So the database should have no nullable columns." Different question. Nullable columns are normal and fine; the model does not have to be shaped like the schema, and mapping between them is the boundary's job (Schema Leakage).
- primitive-obsession
- duplicate-knowledge
Testing it, and how it ages
- A test at the parse boundary for every illegal wire combination, asserting a rejection with a reason rather than a thrown error (Error Modeling).
- In an erased type system, a test that the deserialization path goes through the parser — this is the hole, and asserting the type does nothing at runtime (Contract Tests).
- A data-quality query for the illegal combination in production, which is how the thirty rows were found and how you learn whether the writer is really fixed.
- A compile-time test in languages that support it: an intentionally-unhandled variant that must fail to build, pinning exhaustiveness as a real guarantee rather than a convention.
- Precise models age well while the invariants hold and badly when they weaken. The commonest weakening is a migration that introduces genuinely unknown data, which a maximally-precise model has no room for.
- The healthy pattern is precision proportional to the invariant: model exactly what is always true, and leave the rest as ordinary fields that can be null without meaning anything is broken.
- What eventually forces change: a new consumer that needs a shape the model does not offer, usually a flat one for reporting. That is a projection, not a reason to loosen the model (CQRS in Architecture is the general form).
Where this applies
This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.
- LANGUAGE-SPECIFICThis is the lesson where the language decides what is achievable. In Rust or Haskell,
Paid { paid_at, capture_id }makes the illegal combination unconstructible and exhaustive matching makes a new state a compile error at every consumer — a genuine static guarantee with no runtime cost. In TypeScript a discriminated union gives the same authoring experience and none of the runtime enforcement, because types are erased and a JSON parse produces whatever the JSON contained; the design therefore needs a parse boundary plus a test proving every input goes through it. In Java or C#, sealed types and pattern matching get most of the way, though reflection and deserialization frameworks still bypass constructors. In Python or Ruby the whole thing is convention plus runtime validation, which makes the same model much weaker but also much cheaper to write. - GENERALThe underlying idea — that fields which only make sense together should live together, so no consumer has to check whether they agree — is a modelling principle that holds even where the language enforces nothing.
- CONTESTEDThe strongest opposing view: maximally precise types make the easy case elegant and the awkward case expensive, and real systems are mostly awkward cases — partial data, migrations, third-party feeds, states that were true yesterday. Practitioners who have maintained heavily type-modelled systems for years report that the refactors imposed by a weakened invariant cost more than the bugs the precision prevented, and that a loose model with checks at a few boundaries would have survived the same changes untouched. This is a real experience report rather than laziness, and it is why §74 exists as a counterweight to §73.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — Programming Languages & Runtime Internals — sum types, exhaustive matching and type erasure are language-implementation properties, and they decide whether this lesson describes a compile-time guarantee or a convention with a test behind it.