Discriminated unions, the best pattern in the language
This is, for many experienced developers, the single most valuable pattern in TypeScript. A discriminated union models "a value that is one of several kinds, each carrying its own data", in a way the compiler can check completely — narrowing to the right kind, and proving you handled every one. If you learn one thing from this course to reach for constantly, make it this.
The pattern
A discriminated union is a union of object types that all share a common property — the discriminant (or "tag") — whose value is a different literal in each member:
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rectangle"; width: number; height: number }
| { kind: "triangle"; base: number; height: number };
Three object types, each with a kind property set to a specific literal ("circle",
"rectangle", "triangle"), and each carrying the data that that shape needs — a circle has a
radius, a rectangle has width and height. The shared kind is the discriminant: it is how you
(and the compiler) tell which member you have.
Narrowing by the discriminant
Checking the discriminant narrows the whole object to the matching member, unlocking its specific properties:
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2; // narrowed to circle — radius available
case "rectangle":
return shape.width * shape.height; // narrowed to rectangle — width/height available
case "triangle":
return (shape.base * shape.height) / 2; // narrowed to triangle
}
}
Inside case "circle":, TypeScript knows shape is the circle member, so shape.radius is
available and shape.width would be an error. The kind literal acts as a key that unlocks exactly the
right properties. This is narrowing (last lesson) applied through a switch on the discriminant, and it
is beautifully clean — no instanceof, no in, no type guards, just checking one property.
Try to access the wrong property and TypeScript stops you before you narrow:
function broken(shape: Shape): number {
return shape.radius; // error: Property 'radius' does not exist on type 'Shape'
// (only the circle member has it — you must narrow first)
}
shape.radius is an error because shape might be a rectangle or triangle, which have no radius.
The union forces you to check kind before touching kind-specific data — which is exactly the
safety you want.
Why it is the best pattern
Discriminated unions solve a problem every program has — "this thing is one of several variants" — and solve it completely:
Illegal states become unrepresentable. With a discriminated union, a "circle" simply cannot have
a width — the type does not allow it. Compare with a single flat type where everything is optional:
// the bad alternative — everything optional, illegal states allowed:
type Shape = { kind: string; radius?: number; width?: number; height?: number; base?: number };
// nothing stops { kind: "circle", width: 5 } — a nonsensical circle with a width and no radius
The flat version permits nonsense: a circle with a width, a rectangle with a radius, a shape with none of the properties. The discriminated union permits only valid combinations. This is the course's recurring theme — make illegal states unrepresentable — and discriminated unions are the primary tool for it.
The compiler narrows for you. No manual type guards, no casting — checking the discriminant does all the narrowing.
Exhaustiveness can be checked. The compiler can prove you handled every variant — the next lesson.
The canonical uses
You will reach for this pattern constantly:
Result / success-or-failure — the most useful of all, a type-safe alternative to throwing:
type Result<T> =
| { ok: true; value: T }
| { ok: false; error: string };
function parseAge(input: string): Result<number> {
const n = Number(input);
if (Number.isNaN(n)) return { ok: false, error: `"${input}" is not a number` };
return { ok: true, value: n };
}
const result = parseAge("33");
if (result.ok) {
console.log(result.value); // narrowed — value available, error is not
} else {
console.log(result.error); // narrowed — error available
}
Checking result.ok narrows to the success or failure member, so you can only access value on
success and error on failure — the compiler enforces that you handle both. This is the errors-typed
pattern from the best-practices module, and it is a discriminated union at heart.
UI / async state — the React and front-end staple:
type State<T> =
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; message: string };
Events / actions — a fixed set of things that can happen, each with its own payload:
type Action =
| { type: "increment"; by: number }
| { type: "reset" }
| { type: "setName"; name: string };
Every one of these is "one of a fixed set of kinds, each with its own data", and the discriminant
(ok, status, type) is what narrows. When you see switch (action.type) in a Redux reducer or
if (state.status === "loading") in a component, you are looking at a discriminated union.
Designing one well
Two small conventions make discriminated unions work smoothly:
- Use a consistent discriminant name across your codebase —
kind,type,status, ortag. Pick one and stick with it so the pattern is instantly recognisable. - Make the discriminant a string literal, not a boolean or number, when there are more than two
cases —
"circle" | "rectangle"reads far better than1 | 2, and it logs and debugs clearly. (For exactly two cases, a boolean discriminant likeok: true | falseis fine and common.)
Get in the habit of asking, whenever you model data: is this "one of several kinds"? Then it is a discriminated union. It will be the answer more often than you expect, and reaching for it — instead of a flat type with a pile of optional properties — is one of the clearest marks of someone who thinks in types.
Check your work
What a discriminated union is. A union of object types sharing a common discriminant property whose value is a different literal in each member.
What the discriminant does. Checking it narrows the whole object to the matching member, unlocking that member's specific properties.
Why you cannot access a member-specific property before narrowing. The value might be another member that lacks it — you must check the discriminant first.
The core benefit. Illegal states become unrepresentable — only valid property combinations exist, unlike a flat type with everything optional.
Two more benefits. The compiler narrows for you (no manual guards), and exhaustiveness can be checked.
Three canonical uses. Result (success/failure), UI/async state (loading/success/error), and events/actions.
Two design conventions. A consistent discriminant name (kind/type/status), and a string
literal discriminant for more than two cases.
The question to ask when modelling. Is this "one of several kinds"? Then it is a discriminated union.
Practice
- Write the
Shapediscriminated union and anareafunction that switches onkind. Confirm each case has the right properties. - Try to access
shape.radiuswithout narrowing and read the error. Then narrow and confirm it works. - Contrast with a flat
Shapetype where everything is optional. Construct a nonsensical value (a circle with a width) and note the flat type allows it but the discriminated union does not. - Write a
Result<T>union and aparseAgereturning it. Handle bothokcases and confirmvalueanderrorare only available in the right branch. - Write a
State<T>union for loading/success/error and a function that renders each state. - Write an
Actionunion with three action types and a function that handles each. - Take a flat type with several optional properties from code you know and redesign it as a discriminated union.
Official documentation
- TypeScript — Discriminated Unions — The pattern, in full.
- TypeScript — Narrowing with discriminants — How the discriminant drives narrowing.
- TypeScript — Making illegal states unrepresentable — The design principle behind it.
Next: writing your own type guards.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship