scalable/craft/ts types in anger/lesson 01 cph / /
lesson 01 / 06 · 10 min · updated ·

discriminated unions

model state the compiler can verify. exhaustive switches, assertNever, and api response shapes that can't lie.

the wrong shape

here is the api-response shape that ships in more codebases than it should. it tries to cover every state with one big interface and a handful of optional fields. read it and notice what it lets you write.

user fetch — the everything-optional shape
type UserResponse = {
  status: "loading" | "success" | "error";
  user?: { id: string; name: string };
  error?: { code: number; message: string };
};

function greet(r: UserResponse) {
  if (r.status === "success") {
    // r.user is "User | undefined". the compiler doesn't trust the status.
    return `hello, ${r.user?.name ?? "stranger"}`;
  }
}

the compiler treats user as possibly undefined even after we check status === "success", because nothing in the type binds those two facts together. so we end up writing ?. and ?? in places where, logically, the value must be there. and worse: a caller can hand us { status: "error", user: { id: "1", name: "a" } } and the type system says fine.

the right shape

a discriminated union is one union type where each member carries a literal tag — usually called kind, type, or status — that the compiler can read. when you narrow on the tag, the rest of the variant's fields become available with no optionality theater.

user fetch — discriminated
type UserResponse =
  | { status: "loading" }
  | { status: "success"; user: { id: string; name: string } }
  | { status: "error"; error: { code: number; message: string } };

function greet(r: UserResponse) {
  if (r.status === "success") {
    // r.user is now { id: string; name: string }. no '?.'.
    return `hello, ${r.user.name}`;
  }
  if (r.status === "error") {
    return `sorry: ${r.error.message}`;
  }
  return "…";
}

one type, three variants, one shared key — status — that the compiler uses to route control flow. illegal states (success with no user, error with no error) are now unrepresentable: the type won't accept them. the rest of the file becomes simpler because it doesn't have to defend against impossible inputs.

rule when two fields in a type only co-vary, they belong in the same variant. a boolean plus an optional field is almost always a discriminated union waiting to be written.

narrowing, step by step

narrowing is the mechanism. typescript watches your control flow — an if, a switch, a ternary — and, inside the branch, replaces the union with the subset that satisfies the condition. that subset is the variant whose tag matches.

narrowing — 4 questions

for each snippet, what does typescript infer for the highlighted variable? click through and check yourself.

inside the success branch
type R = { kind: "ok"; value: number } | { kind: "err"; reason: string };

function f(r: R) {
  if (r.kind === "ok") {
    const x = r;
    //    ^?
  }
}

what is the type of `x` at the marked line?

exhaustiveness with assertNever

the never type from question 3 isn't trivia. it's the lever that makes a discriminated union verify — a compile error when you add a new variant and forget to handle it. the pattern is one helper and one default branch.

exhaustive switch
function assertNever(x: never): never {
  throw new Error(`unhandled variant: ${JSON.stringify(x)}`);
}

type Shape =
  | { kind: "circle"; r: number }
  | { kind: "square"; side: number };

function area(s: Shape): number {
  switch (s.kind) {
    case "circle": return Math.PI * s.r ** 2;
    case "square": return s.side ** 2;
    default: return assertNever(s);
  }
}

add a { kind: "rect"; w: number; h: number } to Shape and the default branch stops compiling: s is no longer never in there, so the call to assertNever(s) fails type-check. you've turned a runtime bug ("we forgot to handle rectangles") into a compile-time error. the cost is one helper function you write once.

tip if you don't want to throw at runtime, give assertNever a sane fallback (a default value, a log line). the type is what earns the exhaustiveness check; the runtime behavior is yours to choose.

three ways to model api state

a worked example. you're fetching a user. compare three shapes side by side. click the tabs.

modeling user-fetch state

same problem, three shapes. only one of them makes illegal combinations unrepresentable.

type State = {
  loading: boolean;
  user: User | null;
  error: Error | null;
};

// caller code
if (state.user) {
  show(state.user.name);
  // but state.loading and state.error
  // could ALSO be true. type system
  // can't stop you.
}
tsc sees
// (caller has user) is INDEPENDENT of:
//   - state.loading
//   - state.error
//
// every callsite has to defend against
// "loading: true, user: nonnull" etc.

the three booleans/nulls are independent in the type, so the compiler can't rule out 'loading' + 'user' simultaneously. you read defensively forever.

when not to discriminate

a discriminated union is not free. you pay in two places: the type becomes verbose (one variant per state), and consumers must narrow every time. the pattern earns its keep when the variants actually have different shapes — different fields, different invariants. if every variant has the same fields and just a different label, you don't have a union at all. you have a literal type on one field, plus an object. use that.

not a discriminated union
// fine: one shape with a label.
type Event = { kind: "click" | "hover" | "focus"; at: number };

// this would be over-engineering:
// type Event =
//   | { kind: "click"; at: number }
//   | { kind: "hover"; at: number }
//   | { kind: "focus"; at: number };
//
// no variant has anything the others don't.

what you took away

further reading

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