scalable systems handle growth without breaking
← back · 2 min read ·

illegal states unrepresentable, in typescript

a discriminated union beats a boolean flag every time.

the slogan “make illegal states unrepresentable” comes from yaron minsky writing about ocaml (effective ml, 2010) and was popularized for the f# / typescript world by scott wlaschin’s designing with types. it’s an old idea. it’s also the highest-leverage typescript move i make on any new codebase.

the bug, in code

the type that almost everyone writes:

type Order = {
  id: string;
  status: "pending" | "paid" | "cancelled";
  paidAt?: Date;
  cancelReason?: string;
  refundedAt?: Date;
};

this type allows { status: "pending", paidAt: new Date(), refundedAt: new Date() }. nonsense. the compiler is happy.

the fix: a discriminated union

type Order =
  | { id: string; status: "pending" }
  | { id: string; status: "paid"; paidAt: Date }
  | { id: string; status: "cancelled"; cancelReason: string }
  | { id: string; status: "refunded"; paidAt: Date; refundedAt: Date };

now { status: "pending", paidAt: new Date() } is a type error. the model the type describes matches the model the business has.

the second-order benefit: every function that takes an Order is forced, by exhaustiveness checks, to handle every variant. the next person who adds "disputed" finds out at compile time exactly which functions need updating. zero grep.

the refactor is mechanical:

  1. list the states.
  2. for each state, list the fields that are only meaningful in that state.
  3. write one variant per state with only those fields plus the shared ones.
  4. add a default: never exhaustive check in your switch statements.

the cost: ten minutes once, plus a constructor function that returns the union. the saving: every “wait, can paidAt ever be undefined when status is paid?” question, gone forever.

i write apis this way now. not just internal types — db schemas, queue messages, api responses. anywhere a value has multiple states, a discriminated union beats a sea of optionals.

ts adts aren’t a fancy feature. they’re the basic shape of “this thing is one of n things, and each thing has its own data.” most domain models look like that.

scalable labs·cvr 30091604·github·linkedin·hello@scalable.dk