hraness
Theme
Appearance

invalid states: types, result, and parsing from unknown

the best error is the one you cannot construct

drafted with ai assistance by ben guo

the rest of this lesson is free: add your email to keep reading.

The most common bug in loosely-typed systems is not a wrong answer; it is a wrong value carried far from its birthplace. A string that was supposed to be a path joins a URL; a null flows past three functions before it crashes somewhere innocent. The fix is not more checks; it is types that make the wrong value impossible to construct.

the construction

The rule "model invalid states out of existence" is a type-design rule. If a session can be pending or established but never both, the type should be a union of those states, not a bag of optional fields whose combinations include nonsense. If an operation can only run after verification, the verified value should be a type the unverified one cannot forge: VerifiedEnvelope in vhalla cannot be cloned or constructed outside decode, so "an unverified envelope used as verified" is not a runtime error to catch but a program that does not compile.

This is the deepest form of the lesson: the best error handling is the case you never have to write. Every check you delete by construction is a check that cannot rot.

the parsing convention

Where types cannot reach (the filesystem, the network, a provider response, a stored record), the convention is parse-from-unknown. The foreign value arrives as unknown and earns a type only through a parser that validates shape, rejects unknown keys, and bounds every count, byte size, depth, and list.

Rejecting unknown keys is the load-bearing detail. A parser that accepts {known fields...} + anything silently accepts schema drift: the provider renames id to identifier and the object still parses, now with id === undefined. Rejecting unknown keys converts that drift into a loud boundary error at the exact moment the schema changes. Direct's branded ids follow the same rule one level deeper: a RunId is not a string that happens to look right; it is a brand the parser issues, so a plain string cannot stand in for one.

The bound rule is the same idea applied to size: an unbounded list is a model of "maybe infinite," and systems that accept maybe-infinite input get infinite inputs.

result, not throw

The failure half of the convention is closed results. @hraness/result encodes the pattern the portfolio shares: a function that can fail returns a typed result whose variants enumerate how, instead of throwing into whoever happens to be on the stack. The caller must handle the variants the compiler knows about; an unhandled failure is a type error, not a surprise.

The combination is the actual convention: foreign input parsed at the boundary, invalid states unconstructible inside, failures returned as closed results at the edges. Oh's parsers are the cleanest single example: every stored record and wire value goes through a validator that produces a typed value or a named rejection, and the rest of the kernel never sees the unvalidated shape.

the honest limits

The first limit is that types model what the author remembered to model. A closed enum is closed exactly over the cases the author listed; a state the design forgot is not rejected, it is invisible. The parsing convention covers this partly (unknown keys fail closed), but semantic drift (same keys, changed meaning) is a model problem, not a type problem.

The second is the ergonomics tax. Strict parsing means every new legitimate field needs a parser change, every new state needs a union member, and quick experiments fight the type system before they can run. The portfolio pays that deliberately because the alternative is paying it in incident form later; the lesson does not pretend the trade is free.

The third is that Result propagates the failure, it does not solve it. A closed error type tells you exactly how the call failed; it does not tell you what to do about it. The layers above still have to decide: retry, surface, or fail closed themselves. The type makes that decision visible and forced; the decision is still a decision.

Used as a system, though, the three rules compound: boundaries validate once, interiors trust the types, edges return named failures. The debugging surface that remains is small enough that the other tools in this series (properties, sequences, proofs) can actually cover it.

keep reading: free for subscribers

the rest of this lesson is free. enter your email to subscribe, and every subscriber lesson unlocks in this browser.

already subscribed? enter the same email to unlock.