Designing with Types
Expert · 20 min read · ▶ live playground · ✦ checkpoint
Designing with types means shaping your program so the wrong states are hard or impossible to express. The goal is not fancy type syntax; it is a domain model where the checker can reject broken ideas before they become code paths.
You have learned the pieces separately: literal unions, discriminated unions, branded values, schema validation, Result<T, E>, never exhaustiveness, and strictNullChecks. This lesson ties them into one design principle: make illegal states unrepresentable.
Start with the domain, not the storage shape
A domain is the problem world your code is actually about: orders, invoices, seats, users, permissions, drafts, receipts. A storage shape is often flatter and looser because databases, forms, and JSON have to move bytes around.
The beginner move is to copy the loose shape everywhere:
type LooseCheckout = {
status: "empty" | "editing" | "paying" | "paid" | "failed";
items?: string[];
requestId?: string;
receiptId?: string;
message?: string;
};That looks compact, but it allows nonsense. A "paid" checkout can forget receiptId; an "empty" checkout can carry stale items; a "failed" checkout can have no message. Every function now has to defend against states the business never meant to allow.
The domain-first version says what each state actually contains:
→ Receipt r-1024
The discriminant status is not just a label for runtime code. It is the evidence the checker uses to know which fields exist. The paid branch can read receiptId without optional chaining because the type says a paid checkout always has one.
This is Discriminated Unions plus Exhaustiveness: never as a Checklist, used as design instead of syntax trivia. Add a new state first, run the checker, and every exhaustive decision becomes a precise to-do item.
Parse into trusted values
Parse, don't validate means a boundary function should turn untrusted input into a trusted domain value, or return a typed failure. It should not merely return true and leave the old loose data floating around.
Section 13 taught the runtime boundary: unknown data enters, a schema or predicate checks it, and the returned value is a receipt for a check that ran. The design lesson is broader: parsing is how messy outside shapes become clean inside shapes.
→ Welcome ada@example.com on pro
→ blocked: missing_email
Notice the direction of trust. parseSignup accepts loose boundary facts: maybe there is an email; the plan is only a string. welcome accepts the domain value: a real Signup. The caller cannot accidentally hand it "enterprise" because the parser never returns that as a Plan.
This is the same habit as Schema Validation with Zod, but the point is not one library. The point is the shape of the seam: untrusted data in, domain value or typed failure out.
Prefer total signatures
A partial function is a function that fails for some inputs in a way the signature does not admit: it throws unexpectedly, returns undefined from a lookup, or assumes a nullable value is present. A total function is honest for every input it accepts.
You have several ways to make signatures total:
- Narrow the input type so impossible inputs cannot be passed.
- Return
Result<T, E>for expected failure, from Result Types: Errors as Values. - Return
T | undefinedorT | nullwhen absence is normal, then force callers through Living with strictNullChecks. - Use exhaustive unions so adding a case breaks every incomplete decision.
- Use brands from Branded Types & Assertion Functions when a plain string has passed a domain rule.
The question is not "can I make the error disappear?" It is "what should a caller be forced to know?"
type Seat =
| { kind: "available"; seatId: string }
| { kind: "reserved"; seatId: string; holdId: string };
type ReserveError = { kind: "already_reserved"; seatId: string };
type Result<T, E> =
| { ok: true; data: T }
| { ok: false; error: E };
function reserveSeat(seat: Seat): Result<{ holdId: string }, ReserveError> {
if (seat.kind === "reserved") {
return { ok: false, error: { kind: "already_reserved", seatId: seat.seatId } };
}
return { ok: true, data: { holdId: "hold-" + seat.seatId } };
}That signature tells the truth. Reserving can fail for an already-reserved seat, so the caller must check ok. If your UI only has an available seat because it already filtered and narrowed, write a smaller function that accepts { kind: "available"; seatId: string } and returns the hold directly. Both designs are honest; a thrown surprise in the middle is not.
This is also the standard for public APIs from Designing a Library's Public API. Your types are the contract other code compiles against. Make the contract carry the domain rule, not the wish that every caller remembers the rule.
Next lesson turns these same design habits outward: options objects, builders, branded IDs, dependency injection, and ports/adapters as architecture patterns with typed seams.
Checkpoint
Answer all three to mark this lesson complete