A discriminated union (also called a tagged union or algebraic data type) is a union of object types that all share one field — the discriminant — whose value is a literal type. That shared field tells TS exactly which variant we have.
In simple language: every shape carries a tag (kind, type, status, whatever) that uniquely identifies it. Check the tag, get the right type.
The shape of it
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; size: number }
| { kind: "rectangle"; width: number; height: number };
Every variant has a kind field with a unique string literal. Now TS can tell them apart just by checking kind.
Narrowing on the tag
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2; // shape is { kind: "circle"; radius: number }
case "square":
return shape.size ** 2;
case "rectangle":
return shape.width * shape.height;
}
}
Inside each case, TS knows the exact variant. No casts, no as, no runtime type checks beyond the tag comparison.
Exhaustiveness check with never
The killer feature. Add a default case that assigns to never — if we ever extend the union and forget a case, TS errors right here.
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;
default:
const _exhaustive: never = shape; // ❌ if Shape gains a variant
throw new Error(`Unhandled shape: ${_exhaustive}`);
}
}
Add { kind: "triangle"; ... } to Shape later — every switch that doesn’t handle it lights up red. Compile-time refactor safety.
Real-world pattern: API results
This is the single most common use case. A request can succeed OR fail; the success and failure shapes are different.
type ApiResult<T> =
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; message: string };
function render(result: ApiResult<User>) {
if (result.status === "loading") return "Loading...";
if (result.status === "error") return `Error: ${result.message}`;
return `Hello ${result.data.name}`; // status is "success"
}
Notice how we never access data accidentally on a loading result — the type system blocks it.
Real-world pattern: Redux/state machine actions
type Action =
| { type: "increment" }
| { type: "decrement" }
| { type: "set"; value: number };
function reducer(state: number, action: Action): number {
switch (action.type) {
case "increment": return state + 1;
case "decrement": return state - 1;
case "set": return action.value; // safely access value
}
}
Picking the discriminant
- Use a string literal field (
kind,type,status). Not numeric — strings are clearer in logs. - One field per union. Don’t try to discriminate on two fields at once.
- Same field name across all variants — that’s what makes the discrimination work.
Why not just instanceof?
Discriminated unions work with plain objects — no classes needed. They serialize cleanly to JSON, survive network boundaries, and are the standard pattern in functional languages (Rust enums, Haskell ADTs, OCaml variants).
Interview soundbite
“A discriminated union is a union of object types sharing one literal-typed field. The compiler narrows each branch based on that field — no casts, no runtime overhead. The never exhaustiveness pattern guarantees we handle every case, and any new variant added later breaks the build until we cover it.”