Difficulty: Intermediate
Discriminated unions (also called tagged unions or algebraic data types) are a pattern where each member of a union type has a common property with a unique literal value. This common property is called the discriminant or tag. TypeScript's control flow analysis can use this discriminant to narrow the union to a specific member, giving you access to that member's unique properties.
The pattern requires three ingredients: a set of types that each have a common property, literal types for that property (string or number literals), and a union of those types. When you check the discriminant in a conditional, TypeScript automatically narrows the type within that branch. This makes discriminated unions exceptionally safe - you get compile-time guarantees that you are handling each variant correctly.
Discriminated unions shine when modeling state machines, API responses, action types in reducers, or any scenario where a value can be one of several well-defined variants. They replace brittle patterns like checking for the existence of optional properties or using type assertions. The compiler ensures every variant is accounted for.
For exhaustive checking, you can use the `never` type in a default case. If you add a new variant to the union but forget to handle it in a switch statement, assigning the unhandled case to a `never` variable will produce a compile error. This technique ensures your code stays in sync with your types as the codebase evolves, which is why interviewers frequently ask about it.
type Circle = { kind: "circle"; radius: number };
type Square = { kind: "square"; side: number };
type Triangle = { kind: "triangle"; base: number; height: number };
type Shape = Circle | Square | Triangle;
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.round(Math.PI * shape.radius 2 * 100) / 100;
case "square":
return shape.side 2;
case "triangle":
return (shape.base * shape.height) / 2;
}
}
console.log(area({ kind: "circle", radius: 5 }));
console.log(area({ kind: "square", side: 4 }));
console.log(area({ kind: "triangle", base: 6, height: 3 }));
The `kind` property is the discriminant. In each case branch, TypeScript knows exactly which shape variant it is and provides access to its specific properties.
type Loading = { state: "loading" };
type Success = { state: "success"; data: string[] };
type ErrorState = { state: "error"; message: string };
type ApiState = Loading | Success | ErrorState;
function render(state: ApiState): string {
switch (state.state) {
case "loading":
return "Loading...";
case "success":
return `Items: ${state.data.join(", ")}`;
case "error":
return `Error: ${state.message}`;
}
}
console.log(render({ state: "loading" }));
console.log(render({ state: "success", data: ["a", "b", "c"] }));
console.log(render({ state: "error", message: "Network failure" }));
This pattern is extremely common in frontend applications for modeling async data loading states. Each state variant has its own specific shape.
type Action =
| { type: "INCREMENT"; amount: number }
| { type: "DECREMENT"; amount: number }
| { type: "RESET" };
function reducer(state: number, action: Action): number {
switch (action.type) {
case "INCREMENT":
return state + action.amount;
case "DECREMENT":
return state - action.amount;
case "RESET":
return 0;
default:
const _exhaustive: never = action;
return state;
}
}
let state = 0;
state = reducer(state, { type: "INCREMENT", amount: 5 });
console.log(state);
state = reducer(state, { type: "DECREMENT", amount: 2 });
console.log(state);
state = reducer(state, { type: "RESET" });
console.log(state);
The `never` assignment in the default case ensures all action types are handled. If a new action type were added to the union, this would produce a compile error until handled.
tagged unions, discriminant property, type narrowing, exhaustive checking, pattern matching