Discriminated Unions
A discriminated union (also called a tagged union or algebraic data type) is a union of object types that all share a common property whose type is a unique literal. That shared property — the discriminant — lets TypeScript figure out exactly which member you are dealing with from a single check, then narrow the value to that member and grant access to its unique fields. This pattern is one of the most powerful in the language: it models “a value that is exactly one of these shapes,” makes invalid states unrepresentable, and combines with a never check to guarantee you have handled every case. This page covers the discriminant, narrowing, and exhaustiveness.
Anatomy of a discriminated union
Each member is an object type, and each carries the same property name set to a different literal. That property is the discriminant.
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number }
| { kind: "rectangle"; width: number; height: number };
Here kind is the discriminant, and "circle" | "square" | "rectangle" are its literal values. The non-discriminant fields differ per member: only circles have radius, only rectangles have width and height. A value of type Shape is exactly one of these shapes — never a mix.
Narrowing on the discriminant
Check the discriminant with a switch or if, and TypeScript narrows the union to the matching member inside each branch. Member-specific properties become available only where they exist.
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2; // shape: { kind: "circle"; radius: number }
case "square":
return shape.side ** 2; // shape: square variant
case "rectangle":
return shape.width * shape.height; // shape: rectangle variant
}
}
Because each case matches a literal value of kind, the compiler knows precisely which variant you are in and exposes only that variant’s fields. Accessing shape.radius in the "square" branch would be a compile error — the invalid combination is impossible to write.
Why a literal discriminant is required
The discriminant must be a literal type (typically a string or number literal) shared by every member. A plain string would not distinguish the members, so narrowing would fail.
// ✅ Works: discriminant is a literal in every member
type Ok = { status: "ok"; data: string };
type Err = { status: "error"; message: string };
type Resp = Ok | Err;
function handle(r: Resp) {
if (r.status === "ok") return r.data; // narrowed to Ok
return r.message; // narrowed to Err
}
The compiler builds an internal map from each literal discriminant value to its member. This is also why a discriminated union is more robust than checking for the presence of a property with the in operator — the tag is explicit and unambiguous.
Exhaustiveness checking with never
The real payoff comes when the union grows. By assigning the value to a never variable in the default branch, you turn “forgot a case” into a compile-time error. The never type accepts no value, so any unhandled member breaks the build.
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.side ** 2;
case "rectangle":
return shape.width * shape.height;
default: {
// If every case is handled, `shape` is narrowed to never here.
const _exhaustive: never = shape;
throw new Error(`Unhandled shape: ${(shape as { kind: string }).kind}`);
}
}
}
If you later add { kind: "triangle"; base: number; height: number } to Shape but forget to handle it, shape in the default branch is no longer never, and the assignment const _exhaustive: never = shape fails:
// ❌ Error: Type '{ kind: "triangle"; ... }' is not assignable to type 'never'
This converts a whole class of “missing case” runtime bugs into immediate, localized compiler errors — the single biggest reason to model variant data this way.
Discriminated unions vs alternatives
Compared with optional-everything object types or loose flags, discriminated unions make illegal states unrepresentable and pair perfectly with exhaustiveness checks.
| Approach | Invalid states possible? | Narrowing | Exhaustiveness |
|---|---|---|---|
| One object, all props optional | Yes (any combo) | Manual if guards | None |
Boolean flags (isLoading, isError) | Yes (both true) | Awkward | None |
| Discriminated union | No — exactly one variant | Automatic on the tag | Yes, via never |
A classic application is async state: { status: "loading" } | { status: "success"; data: T } | { status: "error"; error: Error }. You can never be loading and have data, and the compiler forces you to handle every status.
Name the discriminant consistently across your codebase —
kind,type, ortagare common. Avoidtypeif it clashes with other tooling expectations, but whatever you pick, use it everywhere so narrowing is predictable.
Best Practices
- Give every member a shared discriminant property typed as a unique string (or number) literal.
- Narrow with
switch (value.kind)for clarity and to enable exhaustiveness checks. - Add a
defaultbranch that assigns toconst _x: neverto force handling of every member. - Model async and request state as discriminated unions to forbid contradictory flag combos.
- Keep discriminant names consistent (
kind/type/tag) across the codebase. - Prefer a discriminated union over many optional properties or parallel boolean flags.
- Combine with
as constand literal types so discriminants stay narrow, not widened tostring.