Dataslope logoDataslope

Type-Driven Design

Make illegal states unrepresentable. Parse, don't validate. Let the compiler enforce your domain rules.

By now you have the building blocks: pure functions, discriminated unions, exhaustiveness, Option, Result. The question becomes: how do you design programs around them?

The answer is type-driven design: you start from the types that describe your domain, and then write functions that fit the shapes. If the types are tight enough, the compiler will reject huge classes of bugs before you ever run the program.

There are three slogans that capture the approach.

Slogan 1 — "Make illegal states unrepresentable"

If your data model can express a state that should never exist, sooner or later your program will get into that state. The fix is to redesign the type so the bad state cannot be written.

Consider a "remote data" wrapper used to model an API request:

Code Block
TypeScript 5.7

The weak model allows "loading and have data and have an error" — nonsense. With a discriminated union, only the real states are representable:

Code Block
TypeScript 5.7

The four "real" states become four constructable shapes. Every nonsense combination simply doesn't compile.

Another example: form state

Each state carries different data. Modeling them as a single "god object" with optional fields invites bugs. Modeling them as a discriminated union by kind makes each transition obvious:

type Form<A, E> =
  | { kind: "empty" }
  | { kind: "editing";    draft: A }
  | { kind: "submitting"; draft: A }
  | { kind: "submitted";  result: A }
  | { kind: "rejected";   draft: A; error: E };

The transition functions become tiny, total, and exhaustive.

Slogan 2 — "Parse, don't validate"

Validation is a runtime check that returns the same type you already had. Parsing is a runtime check that returns a stricter type — and from then on, the rest of the program knows the constraint is satisfied because of the type signature.

Code Block
TypeScript 5.7

The trick is that NonEmpty is a strict subtype of string. You can use a NonEmpty anywhere a string is expected (because it is a string), but you cannot use a plain string anywhere a NonEmpty is expected. The compiler propagates the guarantee.

This is called a branded type (or nominal type). The __brand field exists only in the type system — at runtime it's still a plain string.

A small parser library

Code Block
TypeScript 5.7

The branded types create a staircase of trust. Each parse step goes one level up; the deeper into the program you go, the stronger the guarantees the types carry.

Slogan 3 — "If it compiles, it (probably) works"

The combination of:

  • Discriminated unions for what shape the data is in right now
  • Branded types for what invariants the value satisfies
  • Exhaustive switch for what the function does in each case
  • Option/Result for what could be missing or wrong

…means that vast classes of bugs simply cannot exist in your program. Not "would be caught by tests" — cannot exist. The compiler refuses to build them.

This is what people mean when they say functional programmers prefer types over tests. Tests are still valuable (especially for business logic), but a huge fraction of "tests" in imperative codebases exist only to re-check things the type system could have enforced once and for all.

A domain modeling challenge

Challenge
TypeScript 5.7
Model a checkout state machine

In checkout.ts, define a discriminated union Checkout with these states:

  • { kind: "cart"; items: ReadonlyArray<{ sku: string; qty: number }> }
  • { kind: "shipping"; items: ...; address: string }
  • { kind: "paying"; items: ...; address: string; card: string }
  • { kind: "confirmed"; orderId: string }

Then implement next(state, event) that handles the events add-address, add-card, and confirm (each carrying the needed payload), throwing if a transition doesn't make sense.

Use exhaustive matching. Do not allow writing a "paying" state without an address — that's what the type system is for.

Expected output

state: shipping
state: paying
state: confirmed orderId=ORD-1

QuestionSelect one

What's the difference between validating a value and parsing it (in the "parse, don't validate" sense)?

They're synonyms in practice

Parsing happens at compile time; validation happens at runtime

Validation checks a constraint and returns the same type; parsing checks a constraint and returns a stricter type that carries the proof going forward

Validation is faster than parsing

On this page