Part 7 · 2 chapters · ~20 min

Domain Modelling

Making illegal states unrepresentable: from a bag of optionals to a union of states with their own fields, branded primitives with validating constructors, booleans and strings replaced by unions, dependent fields as members, state machines in types, and parse, do not validate: the boundary where unknown becomes the domain type once, schemas as the single source of check and type, errors as data, and an inside that needs no checks.

15

Making illegal states unrepresentable

code
// the domain, modelled: states as a union, primitives as brands with one constructor, a state machine in types, a parser at the boundary
import { z } from 'zod'

// brands with validating constructors (the Trust course part 4: money is integers with a currency)
export type Currency = 'NGN' | 'KES' | 'GHS'
export type Money = { readonly amount: bigint; readonly currency: Currency; readonly __brand: 'Money' }
export const Money = { of(amount: bigint, currency: Currency): Money { if (amount < 0n) throw new RangeError('negative'); return { amount, currency, __brand: 'Money' } as Money } }
export type TransferId = string & { readonly __brand: 'TransferId' }
const TransferIdSchema = z.string().regex(/^trf_[a-z0-9]{8}$/).brand<'TransferId'>()

// the states, each with only its own fields; the transitions as a type-level state machine
export type Transfer =
  | { kind: 'draft';     amount: Money; to: AccountNumber }
  | { kind: 'submitted'; id: TransferId; amount: Money; to: AccountNumber; key: IdempotencyKey; at: Date }
  | { kind: 'pending';   id: TransferId; amount: Money; to: AccountNumber; sub: 'processing' | 'in_transit'; eta: Date }
  | { kind: 'settled';   id: TransferId; amount: Money; to: AccountNumber; settledAt: Date; fee: Money }
  | { kind: 'failed';    id: TransferId; amount: Money; to: AccountNumber; reason: string; moneyAt: 'wallet' | 'returned' }
type Next = { draft: 'submitted'; submitted: 'pending' | 'failed'; pending: 'settled' | 'failed'; settled: never; failed: never }
export function transition<K extends Transfer['kind'], N extends Next[K]>(t: Extract<Transfer, { kind: K }>, next: Extract<Transfer, { kind: N }>): Extract<Transfer, { kind: N }> { return next }
// transition(settledTransfer, failedTransfer)   // ✗ N extends never: a settled transfer cannot fail (the compiler refuses the transition)

// the parser at the boundary: the schema is the runtime check AND the type; the inside never sees unknown
const MoneySchema = z.object({ amount: z.coerce.bigint(), currency: z.enum(['NGN', 'KES', 'GHS']) }).transform(m => Money.of(m.amount, m.currency))
export const TransferSchema = z.discriminatedUnion('kind', [
  z.object({ kind: z.literal('settled'), id: TransferIdSchema, amount: MoneySchema, to: AccountNumberSchema, settledAt: z.coerce.date(), fee: MoneySchema }),
  z.object({ kind: z.literal('failed'),  id: TransferIdSchema, amount: MoneySchema, to: AccountNumberSchema, reason: z.string(), moneyAt: z.enum(['wallet', 'returned']) }),
  // … the other members
])
export const parseTransfer = (u: unknown) => TransferSchema.safeParse(u)   // { success: true, data: Transfer } | { success: false, error: ZodError with paths }
from a bag of optionals to a type that cannot contradict itself
  1. The bag admits everything: settledAt on a failed transfer, a negative amount, a currency "naira", a status in the wrong case; every consumer checks every field and the type says nothing.
  2. Enumerate the states (draft, submitted, pending with a sub-state, settled, failed, cancelled: the Trust course part 3) and give each a literal kind and only its own fields. settledAt on a failed transfer becomes a type error.
  3. Brand the primitives with rules: Money (an integer with a currency: the Trust course part 4), TransferId, a Currency literal union; each brand with one validating constructor and no other way to make one, so a value of the type is a value that passed the check.
  4. Replace travelling booleans (eight combinations of which three are real) with a union of four; replace stringly fields with the literal set; the checker autocompletes and rejects the typo.
  5. Dependent fields as members: a card has last4 and a scheme, a bank transfer an account and a bank code, a wallet a wallet id; a card with a bank code cannot be written, and the switch over method is exhaustive (part 6). The most common illegal-state bug and the easiest to fix.
  6. State machines in types: a Next map from each kind to its legal successors and a transition function constrained by it, so a settled transfer cannot be failed at compile time. Costs: members per state, a brand and constructor per rule, parsing at the boundary. Buys: contradictions refused at the write, consumers narrowing instead of checking, an added state surfacing every switch, a model that reads as the specification.
MAKING ILLEGAL STATES UNREPRESENTABLE
from a bag of optionals to a type that cannot hold a contradiction
swipe the figure sideways, or tap expand for full screen
1/6
the bag
The bag: type Transfer = { id?: string; amount?: number; currency?: string; status?: string; failureReason?: string; settledAt?: Date; fee?: number }. Every field optional; settledAt present on a failed transfer; a negative amount; a currency "naira"; a status "SETTLED" versus "settled". Every consumer checks every field; every check can be wrong; the type says nothing.
16

Parse, do not validate

the boundary earns the types the inside trusts
  1. Validate versus parse: validation checks and leaves the value loosely typed, so every consumer re-checks; parsing checks and produces the domain type or an error, so the knowledge is carried in the type. unknown → Result<T, ParseError>.
  2. The schema (zod, valibot, arktype) is the parser and the source of the type (z.infer): the runtime check and the compile-time type cannot drift; brands come from schema transforms; discriminated unions parse into part 6's unions.
  3. Every boundary parses: the API client (api.get(path, Schema) returning a Result), forms into the domain type, URL params into typed routes (part 3), storage and worker and service-worker messages on read. Inside, functions take Transfer, never unknown and never { kind: string }.
  4. Errors as data with paths and messages: the form lands on the field (the Trust course part 7); telemetry records it with the response (the Architecture course part 3); a parse failure on a response is a contract violation and an alert (the Architecture course part 2), never a silent undefined.
  5. Generated from the contract: with an OpenAPI or GraphQL schema the parser and the type are generated, so the API cannot change shape without both changing, and the parse at the boundary is the contract test at runtime (the Big-company FE course part 3's compiler does the same). Hand-written schemas are for boundaries without a contract.
  6. The inside: no defensive checks (an if (!transfer.id) on a Settled is dead code the type refused); brands pass through; serialisation at the outgoing boundary is the inverse projection (part 3). The cost is one parser per boundary; the buy is every function after it.
the exercise
Trace one value from an API response to the screen in your app and count the places it is checked. If it is more than one, the first is where the parser belongs and the rest are the inside not trusting its types.
PARSE, DO NOT VALIDATE
the boundary where unknown becomes the domain type, once, and the inside that needs no checks
swipe the figure sideways, or tap expand for full screen
1/6
validate vs parse
Validate versus parse: function isValid(u: unknown): boolean leaves u as unknown afterwards (or as a type you asserted); every consumer trusts the assertion. function parse(u: unknown): Result produces a Transfer or an error; the consumer has a Transfer and nothing to check. The difference is whether the knowledge gained by checking is carried in the type.