Phase 2 · Type system core · Lesson 2.3
BeginnerDiscriminated unions
Model data that comes in several shapes with a shared tag field, switch on it safely, and get a compile error whenever you forget a case.
18 min
Most real data comes in variants. A payment is by card, bank transfer or wallet, each with different fields. A request is idle, loading, done or failed. The tempting model is one big object with lots of optional fields, and it quietly allows nonsense like "loading and failed and has data". Discriminated unions model each variant exactly, and the compiler then tells you every place you forgot to handle a new one. If you learn one pattern from this phase, make it this one.
The shape of a discriminated union
A discriminated (or tagged) union is a union of object types that all share one property, the discriminant, whose type in each member is a different literal:
type Circle = { kind: "circle"; radius: number };
type Square = { kind: "square"; size: number };
type Rectangle = { kind: "rectangle"; width: number; height: number };
type Shape = Circle | Square | Rectangle;Three ingredients:
- Every member has the same property name (
kind). - Its type in each member is a literal type (
"circle","square", ...), a unit type likenullorundefined, or a union of those. - The members are combined with
|.
String literals are the most common tag, but numbers and booleans work too. A popular result type uses a boolean:
type Result<T> =
| { ok: true; value: T }
| { ok: false; error: string };
function parsePort(text: string): Result<number> {
const port = Number(text);
return Number.isInteger(port) && port > 0
? { ok: true, value: port }
: { ok: false, error: `Invalid port: ${text}` };
}
const result = parsePort("8080");
if (result.ok) {
console.log(result.value); // result: { ok: true; value: number }
} else {
console.log(result.error); // result: { ok: false; error: string }
}Narrowing on the tag
Checking the discriminant narrows the whole object, not just the property. That's what makes the pattern so pleasant:
type Circle = { kind: "circle"; radius: number };
type Square = { kind: "square"; size: number };
type Shape = Circle | Square;
function area(shape: Shape): number {
if (shape.kind === "circle") {
return Math.PI * shape.radius ** 2;
// ^? (parameter) shape: Circle
}
return shape.size ** 2;
// ^? (parameter) shape: Square
}Compare this with the in operator from the last lesson. "radius" in shape also works, but it's based on the presence of a property, which gets fragile as shapes grow and share fields. A dedicated tag is explicit, fast to check, and shows up clearly in logs and JSON.
switch on the tag
With more than two variants, a switch reads best. Each case narrows to the matching member:
type Circle = { kind: "circle"; radius: number };
type Square = { kind: "square"; size: number };
type Rectangle = { kind: "rectangle"; width: number; height: number };
type Shape = Circle | Square | Rectangle;
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.size ** 2;
case "rectangle":
return shape.width * shape.height;
}
}Notice there's no default and no final return, yet TypeScript doesn't complain that the function might return undefined. After all three cases, shape has been narrowed to never: no possibilities are left, so the end of the function is unreachable. That's your first taste of exhaustiveness checking.
Quick check
What is the type of shape inside default?
type Shape = { kind: "circle"; radius: number } | { kind: "square"; size: number };
function f(shape: Shape) {
switch (shape.kind) {
case "circle":
return shape.radius;
default:
return shape; // <- here
}
}Exhaustiveness checking with never
Here's the real payoff. Six months from now someone adds a Triangle to Shape. You want the compiler to list every switch that doesn't handle it yet. There are three ways to get that.
1. Annotate the return type
If a function has an explicit return type that doesn't include undefined, a missing case makes the end of the function reachable, and TypeScript complains:
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; size: number }
| { kind: "triangle"; base: number; height: number };
// @ts-expect-error -- Function lacks ending return statement and return type does not include 'undefined'.
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.size ** 2;
}
}It works, but the error message doesn't say which case is missing, and it doesn't help at all in a switch that doesn't return (for example one that only logs or updates state).
2. An assertNever helper
The classic pattern: in default, pass the value to a function that only accepts never.
function assertNever(value: never): never {
throw new Error(`Unhandled variant: ${JSON.stringify(value)}`);
}
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; size: number }
| { kind: "triangle"; base: number; height: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.size ** 2;
default:
// @ts-expect-error -- Argument of type '{ kind: "triangle"; base: number; height: number; }' is not assignable to parameter of type 'never'.
return assertNever(shape);
}
}If every case is handled, shape is never in default, and the call compiles. If one is missing, shape is that missing member, and the error message names it. Add case "triangle" and the error goes away.
It has a runtime benefit too. Types are erased, so if bad data arrives from an API (say { kind: "hexagon" }), the function throws a clear error instead of silently returning undefined.
3. satisfies never
Since TypeScript 4.9 you can get the same compile-time check without a helper:
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; size: number }
| { kind: "triangle"; base: number; height: number };
function describe(shape: Shape): string {
switch (shape.kind) {
case "circle":
return "round";
case "square":
return "boxy";
default:
// @ts-expect-error -- Type '{ kind: "triangle"; base: number; height: number; }' does not satisfy the expected type 'never'.
shape satisfies never;
throw new Error("unreachable");
}
}satisfies checks a value against a type without changing it. It's a nice zero-import option, but remember it does nothing at runtime, which is why a throw follows it. assertNever gives you the check and the throw in one line.
Destructured and aliased discriminants
You don't always have to write shape.kind inline. Since TypeScript 4.4 and 4.6, narrowing flows through const aliases and destructuring:
type Action =
| { type: "add"; amount: number }
| { type: "reset"; amount: undefined };
function reduce(total: number, action: Action): number {
const { type, amount } = action;
if (type === "add") {
return total + amount;
// ^? const amount: number
}
return 0;
}
function isAdd(action: Action) {
const adding = action.type === "add"; // an aliased condition
if (adding) {
return action.amount.toFixed(2); // action is narrowed here too
}
return "reset";
}The rules: destructured properties must be const (or parameters that are never reassigned), and the object you destructured must not be reassigned either. With let { type, amount } = action, checking type no longer narrows amount: it stays number | undefined, because either variable could be reassigned independently.
Modelling request state
Here's the pattern you'll write most often. First, the version everyone starts with:
type RequestStateBad<T> = {
loading: boolean;
data?: T;
error?: string;
};Count the states it allows: loading is true or false, data is there or not, error is there or not. That's eight combinations, and only four make sense. What does { loading: true, error: "timeout", data: [...] } mean? Every consumer has to guess, and every consumer has to write if (state.data) checks that the types can't help with.
Now the discriminated version:
type RequestState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: string };
function assertNever(value: never): never {
throw new Error(`Unhandled state: ${JSON.stringify(value)}`);
}
function render(state: RequestState<string[]>): string {
switch (state.status) {
case "idle":
return "Press load to start";
case "loading":
return "Loading...";
case "success":
return `Loaded ${state.data.length} items`; // data exists, guaranteed
case "error":
return `Failed: ${state.error}`; // error exists, guaranteed
default:
return assertNever(state);
}
}
console.log(render({ status: "success", data: ["a", "b"] }));
console.log(render({ status: "error", error: "timeout" }));▶ Try it in the TypeScript Playground
Exactly four states, each with exactly the fields it needs. data can't be read while loading, because it isn't there in that type. Try adding a { status: "refreshing"; data: T } member in the playground and watch render light up.
This is the principle often phrased as "make impossible states impossible" (or "unrepresentable"): design types so that invalid combinations can't even be constructed. Optional fields describe what might be present; a union describes what is present in each situation.
Quick check
Which line is a type error?
type State =
| { status: "loading" }
| { status: "success"; data: string[] }
| { status: "error"; error: string };
let s: State;
s = { status: "loading" };
s = { status: "success", data: [] };
s = { status: "error", error: "x" };
s = { status: "loading", data: [] };Common gotchas
The tag must be a literal type. If one member declares kind: string, checking kind === "square" can't rule it out (a string could be "square" too), so narrowing doesn't work as expected.
Widening strikes again. An object built separately gets kind: string, and then it isn't assignable to the union:
type Shape = { kind: "circle"; radius: number } | { kind: "square"; size: number };
function draw(shape: Shape) {}
const c = { kind: "circle", radius: 2 };
// @ts-expect-error -- Types of property 'kind' are incompatible: 'string' is not assignable to '"circle" | "square"'.
draw(c);
draw({ kind: "circle", radius: 2 }); // fine: contextually typed literal
const d: Shape = { kind: "circle", radius: 2 };
draw(d); // fine: annotatedPass object literals directly, annotate the variable, or use as const, as covered in the unions lesson.
Don't reach for as. draw(c as Shape) silences the error, but it also silences a genuine typo like kind: "cirle".
Spot the error
A teammate added a "cancelled" status. This code still compiles, yet the UI shows a blank message for cancelled requests. What's wrong, and how do you make the compiler catch it next time?
type Status =
| { status: "loading" }
| { status: "done"; data: string }
| { status: "cancelled" };
function message(s: Status) {
switch (s.status) {
case "loading":
return "Loading";
case "done":
return s.data;
}
}Show the answer
message has no return type annotation and no exhaustiveness check, so TypeScript just infers string | undefined and says nothing. For "cancelled" it returns undefined. Add the missing case and a check so the next new status is caught:
function assertNever(value: never): never {
throw new Error(`Unhandled: ${JSON.stringify(value)}`);
}
type Status =
| { status: "loading" }
| { status: "done"; data: string }
| { status: "cancelled" };
function message(s: Status): string {
switch (s.status) {
case "loading":
return "Loading";
case "done":
return s.data;
case "cancelled":
return "Cancelled";
default:
return assertNever(s);
}
}Either the : string return type or the assertNever would have flagged the missing case on its own; together they also protect against bad runtime data.
Recap
- A discriminated union is a union of object types with a shared property whose type is a different literal in each member.
- Checking the tag (
if,switch,===) narrows the whole object to the matching member. - Exhaustiveness: after all cases, the value is
never. UseassertNever(x)(compile check plus runtime throw),x satisfies never(compile check only), or an explicit return type. - Destructured and aliased discriminants narrow too, as long as they're
constand the source isn't reassigned. - Model correlated fields as variants (
idle | loading | success | error), not as optional fields: make impossible states impossible. - Separately built objects widen
kindtostring; pass literals directly, annotate, or useas const.
Interview cards
1 / 7