TypeScript63 min total · 21 parts
TypeScript Fundamentals: Types, Interfaces, Generics, and Why It Catches Bugs Before Runtime
Part 7 of 21 · ~3 min
Discriminated Unions
ContactInfo a moment ago worked with "in" because its two shapes genuinely have different keys — there's no email on the phone variant to get confused about. The queue's review status doesn't get that for free. score and reason really are different fields, but the moment a union grows a third or fourth variant with partially overlapping fields, checking property presence by hand stops being reliable. What actually scales is giving every variant one shared field whose value — not its presence — says which shape you're holding:
type ReviewStatus =
| { status: "pending" }
| { status: "scored"; score: number }
| { status: "rejected"; reason: string };
That shared, literally-typed status field is a discriminant, and a union built around one is a discriminated union. Switch on it, and TypeScript narrows the entire object per branch, not just one property at a time:
function summarize(status: ReviewStatus): string {
switch (status.status) {
case "pending":
return "Awaiting review";
case "scored":
return `Scored ${status.score}/100`; // .score only exists in this branch, and TS knows it
case "rejected":
return `Rejected: ${status.reason}`; // same for .reason here
}
}
Compare this to the flat shape most people reach for before they've met discriminated unions:
interface ReviewStatusFlat {
reviewed: boolean;
score?: number;
reason?: string;
}
const brokenStatus: ReviewStatusFlat = {
reviewed: false, // "not yet reviewed"
score: 91, // and also "already scored 91" — nothing here objects
};
brokenStatus compiles without a single complaint, because reviewed and score are just two independent optional-ish fields as far as the type system is concerned — nothing says they have to agree with each other. ReviewStatus doesn't have this failure mode, not because TypeScript is smarter about it, but because the shape itself doesn't leave room for the contradiction: there's no { status: "pending", score: number } variant to accidentally construct.
Pairing a discriminated union with a switch buys you one more thing: exhaustiveness checking — TypeScript's way of proving every branch actually got written.
function assertNever(x: never): never {
throw new Error(`Unhandled review status: ${JSON.stringify(x)}`);
}
function summarize2(status: ReviewStatus): string {
switch (status.status) {
case "pending": return "Awaiting review";
case "scored": return `Scored ${status.score}/100`;
case "rejected": return `Rejected: ${status.reason}`;
default: return assertNever(status);
}
}
The trick is in what status is typed as inside that default branch, once every named case above it has been ruled out: never — TypeScript's way of saying there's nothing left this could possibly be. assertNever only accepts never, so as long as every real case is handled, the call type-checks and the function is provably safe. Add a fourth variant to ReviewStatus later — an applicant withdraws on their own, say, { status: "withdrawn"; withdrawnAt: string } — and forget to add a matching case, and status inside default is no longer never; it's { status: "withdrawn"; withdrawnAt: string }, which assertNever refuses. The build breaks at the missing case, not three weeks later when a reviewer asks why withdrawn applicants still say "Awaiting review."