F11. Lifted Invariants
Diagnoses: D11. Re-Litigated Invariants Related fixes:
- F4 (Parse, Don’t Validate) — same establish-once principle, applied at the external boundary; F11 extends it to invariants that emerge between internal stages, after data is already typed
- F2 (Data Flow Pipelines) — the right place to lift an invariant is usually a named pipeline stage; the lifted type then flows visibly through subsequent stages
- F5 (Growing a Language) — co-varying fields often deserve a new compound type with a name from the domain
- F7 (Cohesion and Semantic Integrity) — bundling co-varying values into one type is a cohesion improvement; splitting them apart loses the constraint they were under
- F10 (Single Point of Knowledge) — D10’s data-shape sibling: a constraint established once vs. re-checked at many call sites, just as D10 is about a rule defined once vs. followed by repetition
The concept
Section titled “The concept”Some invariants don’t live at the system boundary; they emerge inside the pipeline as data is enriched, sorted, joined, or filtered. Two kinds matter in practice:
- Shape invariants — like eg: the array is sorted, or the list is non-empty, or every record has been enriched with a
tenant_id, or every row carries a category, etc. - Logical / relational invariants — two or more values are constrained to be in valid combinations:
statusandexpires_at,startandend, currency and amount, a parent reference and its children.
When such an invariant is not lifted into the type system, downstream consumers must each independently know about and uphold it. Then any of the following goes wrong:
- A flag describing the invariant (“is this sorted?”, “has this been enriched?”) rides through the pipeline as a runtime parameter, threaded into multiple function signatures. Each stage that cares branches on the flag — and the bug is the inevitable consumer that silently assumes one side of it.
- Two callers ask the same question and fall out of sync: one branches on both directions correctly, another reads the data as if a chosen direction is guaranteed.
- A relational constraint between fields is honored everywhere except in one new code path that doesn’t know the convention.
The cost is paid twice: once in the bugs, and once in the cognitive load of every reader who must reconstruct what is true about the data at this point in the program.
Remedy
Section titled “Remedy”Establish the invariant at a single point, then remove the question from downstream code’s vocabulary. Two approaches, in order of preference:
1. Concrete nominal type (preferred)
Section titled “1. Concrete nominal type (preferred)”Define a type whose existence is the invariant. The type’s only constructor establishes the invariant; downstream code receives the type and may not re-decide.
For shape invariants: a discriminated record with a kind tag (e.g. { kind: 'chrono', rows: T[] }) or a class with a private constructor and a make / normalize factory. The discriminator is structural — TypeScript tracks it through .map, .filter, destructuring, and spreads without ceremony.
For co-varying fields: replace the independent fields with a single compound type whose shape can only express valid combinations. A discriminated union over status so that expires_at exists exactly when status === 'active'. A DateRange whose smart constructor refuses start > end. An Amount that pairs value and currency so currency-less arithmetic is unrepresentable.
After lifting, downstream code that previously branched on a flag now demands the typed argument. Any function that does not preserve the invariant cannot return the type, so the place where order or shape might be lost is exactly the place a reviewer looks.
2. Reusable guard + convention
Section titled “2. Reusable guard + convention”When the constraint spans values held in different domains and cannot be co-located in a single type — a foreign-key relationship between records owned by different services, a constraint that requires runtime context to evaluate, an invariant that reaches across module boundaries you don’t control — write one guard function that asserts the invariant and throws loudly on violation. Call this guard at every boundary where the invariant must hold.
The guard is the single point of definition. Its name and location are the documentation. Avoid silent boolean returns: a boolean discards the evidence and lets callers ignore it (this is the boolean-blindness failure mode of D4, recurring at the internal boundary). The guard either succeeds or throws.
This is a fallback, not a peer. A nominal type is checked at compile time at every call site for free; a guard is checked only at sites that remember to call it. Reach for the guard when the type approach is genuinely infeasible, not when it would be slightly more work.
What about phantom brands?
Section titled “What about phantom brands?”A phantom brand (T & { readonly __invariant: unique symbol }) sits between these two and looks attractive in TypeScript and similar languages: zero runtime presence, the carrier remains a plain T or T[]. Avoid it. Built-in operations — .map, .filter, spread, destructuring — return the unbranded base type, and the brand has to be re-attached at every hop via unchecked casts. The casts are confessions: they tell the reader “trust me,” not “the compiler verified.” If the type-level approach is going to be paid for, pay for the version the compiler actually checks — the concrete nominal record.
Where to lift
Section titled “Where to lift”Lift the invariant at the earliest stage where it can be made true:
- For sortedness or canonical form: at the parse boundary, before any consumer sees the data.
- For enrichment: in the function that performs the enrichment, returning the enriched type.
- For relational constraints between fields: in the smart constructor of the compound type, called wherever the related fields first become known together.
After lifting, audit downstream code: every conditional that branched on the question, every defensive re-check, every comment explaining “we know this is sorted because…” should be deletable. If they aren’t, the invariant has not actually been lifted — the type is decorative, and the question is still being re-litigated.