Union Types and Narrowing · Lesson 10 of 17
Discriminated Unions and Exhaustive Checks
A discriminated union is a union of object types that share one literal property – the "tag" – like status: "loading" | "success" | "error". Checking the tag tells TypeScript exactly which shape you have. It's one of the most useful patterns in TypeScript: request states in React, Redux actions, payment results and messages all use it.
In this lesson
- Building a discriminated union
- Narrowing with switch
- Exhaustive checking with
never - Making impossible states impossible
A discriminated union
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rectangle"; width: number; height: number }
| { kind: "triangle"; base: number; height: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle": return Math.PI * shape.radius ** 2; // shape: circle
case "rectangle": return shape.width * shape.height; // shape: rectangle
case "triangle": return 0.5 * shape.base * shape.height; // shape: triangle
}
}
const shapes: Shape[] = [
{ kind: "circle", radius: 1 },
{ kind: "rectangle", width: 3, height: 4 },
{ kind: "triangle", base: 6, height: 2 },
];
for (const s of shapes) console.log(s.kind, area(s).toFixed(2));
circle 3.14 rectangle 12.00 triangle 6.00
Each member has a kind with a different literal value. Inside each case, TypeScript knows the exact member, so shape.radius is only allowed in the circle branch.
Exhaustive checks with never
What happens when someone adds a new shape later? Make the compiler find every switch that doesn't handle it:
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number }
| { kind: "hexagon"; side: number }; // newly added
function assertNever(value: never): never {
throw new Error(`Unhandled case: ${JSON.stringify(value)}`);
}
function perimeter(shape: Shape): number {
switch (shape.kind) {
case "circle": return 2 * Math.PI * shape.radius;
case "square": return 4 * shape.side;
default: return assertNever(shape); // hexagon reaches here
}
}
console.log(perimeter({ kind: "square", side: 2 }));
main.ts(14,39): error TS2345: Argument of type '{ kind: "hexagon"; side: number; }' is not assignable to parameter of type 'never'.How the never check works
- Each case removes a memberafter "circle" and "square", only hexagon is left
- In default, shape should be neverif every member was handled, nothing is left
- A member is left overpassing it to assertNever(value: never) is a type error
- Add the missing casethe error disappears
With the hexagon case added, the code compiles and runs:
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number }
| { kind: "hexagon"; side: number };
function assertNever(value: never): never {
throw new Error(`Unhandled case: ${JSON.stringify(value)}`);
}
function perimeter(shape: Shape): number {
switch (shape.kind) {
case "circle": return 2 * Math.PI * shape.radius;
case "square": return 4 * shape.side;
case "hexagon": return 6 * shape.side;
default: return assertNever(shape);
}
}
console.log(perimeter({ kind: "square", side: 2 }), perimeter({ kind: "hexagon", side: 1.5 }));
8 9
Request state: impossible states made impossible
interface Course { title: string; lessons: number }
// Bad: { loading: boolean; data?: Course[]; error?: string } allows loading + error + data at once.
type RequestState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: Course[] }
| { status: "error"; error: string };
function render(state: RequestState): string {
switch (state.status) {
case "idle": return "Click to load courses";
case "loading": return "Loading…";
case "success": return state.data.map((c) => `${c.title} (${c.lessons})`).join(", ");
case "error": return `Something went wrong: ${state.error}`;
}
}
const states: RequestState[] = [
{ status: "idle" },
{ status: "loading" },
{ status: "success", data: [{ title: "TypeScript", lessons: 20 }, { title: "SQL", lessons: 30 }] },
{ status: "error", error: "Network timeout" },
];
for (const s of states) console.log(render(s));
Click to load courses Loading… TypeScript (20), SQL (30) Something went wrong: Network timeout
Why this matters in React
With separate loading, error and data fields, nothing stops "loading and error at the same time" bugs. A discriminated union makes those states unrepresentable, and you can only reach data after checking status === "success". The same pattern types Redux and useReducer actions.
Practice
Try it yourself
Model a payment as
{ method: "card"; last4: string } | { method: "upi"; vpa: string } | { method: "cod" }and write an exhaustivelabel().Show solution
type Payment = { method: "card"; last4: string } | { method: "upi"; vpa: string } | { method: "cod" }; function label(p: Payment): string { switch (p.method) { case "card": return `Card ending ${p.last4}`; case "upi": return `UPI ${p.vpa}`; case "cod": return "Cash on delivery"; default: { const unreachable: never = p; return unreachable; } } } const all: Payment[] = [{ method: "card", last4: "4242" }, { method: "upi", vpa: "asha@bank" }, { method: "cod" }]; for (const p of all) console.log(label(p));Card ending 4242 UPI asha@bank Cash on delivery
Quick check
What makes a union "discriminated"?
Every member has a common property with a distinct literal type (the tag).
Why assign to a never variable in default?
If any case is missing, the leftover member isn't never, so the code won't compile.
Why is a union better than optional loading/data/error fields?
It makes impossible combinations (loading + error) unrepresentable.
Summary
- Discriminated unions share a literal tag; switching on it narrows to the exact member.
- assertNever / a never variable in default makes switches exhaustive.
- Use them for request states, actions and results to make invalid states impossible.
Finished this lesson?
Mark it complete to track your progress, then carry on with the next lesson.