What is a "discriminated union"?
What is a discriminated union?
A discriminated union is a union of objects that share a
common tag property (called the discriminant or tag),
whose value uniquely determines the object's type.
This lets TypeScript recognize which exact variant of the type is being used, and automatically narrow the type (type narrowing) during checks.
A simple example
type Circle = {
kind: "circle";
radius: number;
};
type Square = {
kind: "square";
side: number;
};
type Shape = Circle | Square;Here:
Shapeis a union (Circle | Square);- the
kindfield is the discriminant, it tells the variants apart.
Usage with type narrowing
function area(shape: Shape): number {
if (shape.kind === "circle") {
// here shape: Circle
return Math.PI * shape.radius ** 2;
} else {
// here shape: Square
return shape.side ** 2;
}
}TypeScript automatically determines that in each if/else block
the type has narrowed to the correct variant (Circle or Square).
How this works
TypeScript sees:
- a union (
Circle | Square), - a common property (
kind), - and unique string literals in that property.
After the check shape.kind === "circle",
the compiler knows that in this context shape is definitely Circle.
An example with several variants
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number }
| { kind: "triangle"; base: number; height: number };
function getArea(shape: Shape) {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.side ** 2;
case "triangle":
return (shape.base * shape.height) / 2;
default:
const _exhaustive: never = shape; // an exhaustiveness check
return _exhaustive;
}
}Here:
switchautomatically narrows the type bykind.neverindefaultguarantees that all variants are handled.
Advantages of a discriminated union
1. Safe branching - TS guarantees that you have handled all cases.
2. No as and no casts - the type is determined automatically.
3. Works great with switch, if, and pattern-like checks.
4. Well suited for describing states, actions, errors, and events.
Real-world examples
1. Loading states (state machine pattern)
type LoadingState = { status: "loading" };
type SuccessState = { status: "success"; data: string };
type ErrorState = { status: "error"; error: Error };
type RequestState = LoadingState | SuccessState | ErrorState;
function render(state: RequestState) {
switch (state.status) {
case "loading": return "Loading...";
case "success": return `Data: ${state.data}`;
case "error": return `Error: ${state.error.message}`;
}
}2. Redux-like actions
type Action =
| { type: "add"; payload: number }
| { type: "remove"; id: string }
| { type: "reset" };
function reducer(state: number[], action: Action) {
switch (action.type) {
case "add":
return [...state, action.payload];
case "remove":
return state.filter((_, i) => i.toString() !== action.id);
case "reset":
return [];
}
}3. API responses
type ApiResponse<T> =
| { kind: "ok"; data: T }
| { kind: "error"; message: string };
function handleResponse(res: ApiResponse<string>) {
if (res.kind === "ok") {
console.log("OK:", res.data);
} else {
console.error("Error:", res.message);
}
}The difference from a regular union
| Regular union | Discriminated union |
|---|---|
type A = {a: number} | {b: string} | type A = { kind: "x"; a: number } | { kind: "y"; b: string } |
| When checking properties, TypeScript does not know which variant it is | TypeScript automatically narrows the type by kind |
Checks must be done manually ('a' in obj) | Comparing the tag is enough (obj.kind === 'x') |
Summary
| Concept | Description |
|---|---|
| Discriminant (tag) | A common property with unique literal values that determines the type variant |
| Discriminated union | A union of types distinguished by a tag |
| Main advantage | TypeScript narrows the type itself, based on the tag's value |
| Typical use | Loading states, actions, result variants, finite state machines |
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.