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.
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.
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.
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.
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?
type Shape =
| { kind: "circle"; r: number }
| { kind: "square"; side: number }
| { kind: "rect"; w: number; h: number };
function area(s: Shape) {
switch (s.kind) {
case "circle":
const c = s;
// ^?
}
}what is the type of `c`?
type R = { kind: "ok"; value: number } | { kind: "err"; reason: string };
function f(r: R) {
if (r.kind === "ok") return r.value;
if (r.kind === "err") return -1;
const leftover = r;
// ^?
}what is the type of `leftover`?
type R = { kind: "ok"; value: number } | { kind: "err"; reason: string };
function f(r: R) {
if (r.kind !== "ok") {
const e = r;
// ^?
}
}what is the type of `e`?
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.
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.
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.
}// (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.
type State = {
status: "loading" | "ready" | "error";
user?: User;
error?: Error;
};
if (state.status === "ready") {
// state.user is User | undefined
show(state.user?.name ?? "?");
// optional chain on a value that
// 'should' be there. brittle.
}// status === "ready"
// ⇒ no info about user/error
//
// state.user: User | undefined
// state.error: Error | undefinedstatus narrows itself but not the siblings. you still pay the optional-chain tax and an 'impossible' caller can still pass status:"ready" with no user.
type State =
| { status: "loading" }
| { status: "ready"; user: User }
| { status: "error"; error: Error };
if (state.status === "ready") {
// state.user is User — period.
show(state.user.name);
}// status === "ready"
// ⇒ this exact variant
//
// state.user: User
// state.error: does not existnarrowing on status picks one variant; the other variants' fields aren't even on the type. illegal combinations were never expressible.
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.
// 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
-
two co-varying fields belong in one variant. one tag — usually
kindorstatus— discriminates the union. -
narrowing on the tag (in
if,switch, or ternary) lets you read sibling fields without optional chains. -
pair the union with
assertNeverin adefaultbranch. adding a variant becomes a compile error you can't miss. - illegal states become unrepresentable: a caller can't even type a bad object literal. defensive code disappears.
- use the pattern when variants differ in shape. if they don't, a single object with a literal-typed field is enough.
further reading
- typescript handbook · narrowing — the canonical reference. short.
- /posts/ · illegal states unrepresentable — the design principle this lesson is one tool for.