A discriminated union (also called a tagged union) is a union of object types that share one literal-typed property, the discriminant. Checking that property tells TypeScript exactly which member you are holding. Combined with the never type, it lets the compiler prove that every case is handled, so adding a new variant produces compile errors in every place that forgot it. This is the single most useful pattern for modeling state in TypeScript.
interface Circle { kind: "circle"; radius: number }
interface Square { kind: "square"; side: number }
interface Triangle { kind: "triangle"; base: number; height: number }
type Shape = Circle | Square | Triangle;Three rules make this work:
kind)."circle", "square", ...).Now any comparison on kind narrows the union:
function area(shape: Shape): number {
if (shape.kind === "circle") {
return Math.PI * shape.radius ** 2; // shape: Circle
}
if (shape.kind === "square") {
return shape.side ** 2; // shape: Square
}
return (shape.base * shape.height) / 2; // shape: Triangle (only option left)
}Accessing shape.radius before narrowing is an error because radius does not exist on every member. The discriminant does not have to be a string: numeric literals, booleans and enum members work too, and the property is conventionally named kind, type, status or tag.
nevernever is TypeScript's bottom type: it has no values. A function that always throws returns never, an impossible intersection collapses to never, and a union that has been fully narrowed becomes never. That last property is what makes exhaustiveness checking possible:
function assertNever(value: never): never {
throw new Error(`Unexpected value: ${JSON.stringify(value)}`);
}
function describe(shape: Shape): string {
switch (shape.kind) {
case "circle": return "round";
case "square": return "four equal sides";
case "triangle": return "three sides";
default: return assertNever(shape); // shape is never here
}
}If someone later adds { kind: "hexagon" } to Shape, the default branch receives a Hexagon instead of never, and the call to assertNever fails to compile with "Argument of type 'Hexagon' is not assignable to parameter of type 'never'". The compiler has found every switch that needs updating.
A lighter alternative uses satisfies:
default: {
const _exhaustive: never = shape;
return _exhaustive;
}
// or, without an unused variable:
default:
shape satisfies never;
throw new Error("unreachable");Enabling noFallthroughCasesInSwitch and noImplicitReturns in tsconfig.json adds further protection for switch-heavy code.
Discriminated unions shine wherever a value can be in one of several mutually exclusive states. Compare the two designs below:
// Weak: every field is optional and nonsense combinations are allowed
interface FetchStateLoose {
loading: boolean;
data?: string[];
error?: string;
}
// Strong: each state carries exactly the data that exists in that state
type FetchState =
| { status: "loading" }
| { status: "success"; data: string[] }
| { status: "error"; error: string };With FetchStateLoose, { loading: true, data: [...], error: "x" } is valid and the UI has to guess. With FetchState, data is only reachable after checking status === "success", so impossible states cannot be represented. The same idea applies to Redux-style actions ({ type: "add"; item: Item } | { type: "remove"; id: number }), API responses, form steps and parser tokens.
switch (true) narrowing (TypeScript 5.3+) lets each case be an arbitrary boolean expression, useful when the discriminant is a range or a computed check.const { type, payload } = action;, a check on type narrows payload, provided the destructuring is const and every member declares both properties.[ "ok", Data ] | [ "err", Error ] is discriminated by index 0.kind: string instead of a literal. A string cannot distinguish members, so nothing narrows.default branch. Without it, a switch silently returns undefined for new variants.What property makes a union "discriminated"?
if or switch narrows the whole object to that member.never is the bottom type; passing the fully narrowed value to assertNever proves exhaustiveness at compile time.switch, which is exactly what you want.Next lesson: Function Overloads and Typing this — give one function several signatures and type the this value explicitly.