Discriminated Unions
Reviewed & published by Brayan K
A discriminated union is a union of object types that all share one property holding a unique literal value — the discriminant — so that checking that single field lets TypeScript narrow the value to exactly one member and safely access the fields that member carries.
Part of the free TypeScript course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn
- Add a common literal discriminant field (kind / type / status)
- Narrow a union with a switch on the discriminant
- Access only the fields that belong to the matched member
- Guarantee every case is handled with a never exhaustiveness check
- Model request state as idle | loading | success | error
- Model success-or-failure with a Result discriminated union
💡 Real-World Analogy
Think of mail arriving at a sorting office. Every envelope has a class stamp in the corner — "Letter", "Parcel", "Registered". That stamp is the discriminant. The clerk reads the stamp first, and only then knows what to expect inside: a parcel has a weight, a registered item has a tracking number, a plain letter has neither. You'd never weigh a letter or look for a tracking number on a postcard. A discriminated union works the same way — read the tag, and the compiler reveals exactly which fields are valid for that kind of "envelope".
1. The Discriminant & Narrowing with switch
Give every member of the union the same field — here kind — set to a distinct literal. When you switch on that field, TypeScript narrows the value inside each case to the single member that matches, so only that member's fields are accessible:
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rect"; width: number; height: number }
| { kind: "square"; size: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle": return Math.PI * shape.radius ** 2; // radius is known here
case "rect": return shape.width * shape.height; // width/height known
case "square": return shape.size ** 2; // size known
}
}
// Outside the matching case, the field is off-limits:
// case "circle": return shape.width; // ❌ 'width' does not exist on circle// === A discriminated union: each shape has a common literal "tag" field ===
// In TS:
// type Shape =
// | { kind: "circle"; radius: number }
// | { kind: "rect"; width: number; height: number }
// | { kind: "square"; size: number };
// The shared "kind" field is the DISCRIMINANT. At runtime, each value is just
// an object whose "kind" tells you which shape it is:
const circle = { kind: "circle", radius: 5 };
const rect = { kind: "rect", width: 4, height: 3 };
const square = { kind: "square", size: 2 };
// Narrowing: switch on the discriminant and use ONLY that shape's fields.
function area(shape) {
switch (shape.kind) {
case "circle": return Math.PI * shape.radius ** 2;
case "rect": return shape.width * shape.height;
case "square": return shape.size ** 2;
}
}
console.log("circle area:", area(circle).toFixed(2)); // circle area: 78.54
console.log("rect area:", area(rect)); // rect area: 12
console.log("square area:", area(square)); // square area: 42. Exhaustiveness Checks with never
A discriminated union shines when you make the compiler prove you handled every case. In the default branch, assign the value to never (or pass it to an assertNever helper). If a case is missing, the value isn't never and the build fails — a forgotten variant becomes a compile error:
function assertNever(x: never): never {
throw new Error("Unhandled variant: " + JSON.stringify(x));
}
function describe(shape: Shape): string {
switch (shape.kind) {
case "circle": return "a circle of radius " + shape.radius;
case "rect": return "a rectangle";
case "square": return "a square";
default: return assertNever(shape); // ✅ shape is 'never' if all handled
}
}
// Add a new variant to Shape but forget a case, and TypeScript errors at the
// assertNever line: "Argument of type '{ kind: "triangle"; ... }'
// is not assignable to parameter of type 'never'."// === Exhaustiveness checks with never ===
// In TS, a "default: const _exhaustive: never = shape" makes the compiler ERROR
// if you forget a case — because an unhandled variant isn't assignable to never.
// At runtime we can mimic the safety net by throwing on an unknown tag.
function assertNever(value) {
throw new Error("Unhandled variant: " + JSON.stringify(value));
}
function describe(shape) {
switch (shape.kind) {
case "circle": return "a circle of radius " + shape.radius;
case "rect": return "a " + shape.width + "x" + shape.height + " rectangle";
case "square": return "a square of size " + shape.size;
default: return assertNever(shape); // compile-time guard in TS
}
}
console.log(describe({ kind: "circle", radius: 5 })); // a circle of radius 5
console.log(describe({ kind: "rect", width: 4, height: 3 })); // a 4x3 rectangle
// If a NEW variant is added to the type but not handled, TS flags the default
// line at COMPILE time. Here's the runtime version catching an unknown tag:
try {
describe({ kind: "triangle", base: 3, height: 6 });
} catch (e) {
console.log("caught:", e.message);
}
// caught: Unhandled variant: {"kind":"triangle","base":3,"height":6}3. Modelling State & Results
The pattern's real payoff is modelling state machines so that impossible states are unrepresentable. A request can be idle, loading, success (with data), or error (with a message) — and a reducer switches on the discriminant to move between them. You can't accidentally read data while loading:
type State =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: string[] }
| { status: "error"; message: string };
type Action =
| { type: "FETCH" }
| { type: "RESOLVE"; data: string[] }
| { type: "REJECT"; message: string }
| { type: "RESET" };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "FETCH": return { status: "loading" };
case "RESOLVE": return { status: "success", data: action.data };
case "REJECT": return { status: "error", message: action.message };
case "RESET": return { status: "idle" };
}
}// === Modelling state & results with a discriminated union (reducer-style) ===
// A very common pattern: an async request's state.
// In TS:
// type State =
// | { status: "idle" }
// | { status: "loading" }
// | { status: "success"; data: string[] }
// | { status: "error"; message: string };
// A reducer switches on "status" — the discriminant — to compute the next state.
function reducer(state, action) {
switch (action.type) {
case "FETCH": return { status: "loading" };
case "RESOLVE": return { status: "success", data: action.data };
case "REJECT": return { status: "error", message: action.message };
case "RESET": return { status: "idle" };
default: throw new Error("unknown action");
}
}
let state = { status: "idle" };
console.log("start:", state); // start: { status: 'idle' }
state = reducer(state, { type: "FETCH" });
console.log("after FETCH:", state); // after FETCH: { status: 'loading' }
state = reducer(state, { type: "RESOLVE", data: ["a", "b"] });
console.log("after RESOLVE:", state); // after RESOLVE: { status: 'success', data: [ 'a', 'b' ] }
// Render based on the discriminant — only the valid fields are available per state.
function view(s) {
switch (s.status) {
case "idle": return "Press load to begin.";
case "loading": return "Loading...";
case "success": return "Loaded " + s.data.length + " items.";
case "error": return "Error: " + s.message;
}
}
console.log("view:", view(state)); // view: Loaded 2 items.🎯 Your Turn
Complete a function that narrows a Payment union by its method discriminant. Fill in the blanks and match the expected output.
// 🎯 YOUR TURN — replace each ___ then press "Try it Yourself".
// A Payment discriminated union, narrowed by the "method" field:
// type Payment =
// | { method: "cash"; amount: number }
// | { method: "card"; amount: number; last4: string }
// | { method: "coupon"; code: string };
function pay(payment) {
switch (payment.___) { // 👉 which field is the discriminant?
case "cash":
return "Paid $" + payment.amount + " in cash";
case "card":
return "Charged $" + payment.amount + " to card ****" + payment.last4;
case "coupon":
return "Redeemed coupon " + ___; // 👉 payment.code
}
}
console.log(pay({ method: "cash", amount: 20 }));
console.log(pay({ method: "card", amount: 50, last4: "4242" }));
console.log(pay({ method: "coupon", code: "SAVE10" }));
// ✅ Expected output:
// Paid $20 in cash
// Charged $50 to card ****4242
// Redeemed coupon SAVE10Common Errors (and the fix)
- ❌ Discriminant typed as string: if kind is string instead of a literal, narrowing fails and every field looks unavailable. ✅ Type each member's discriminant as an exact literal ("circle").
- ❌ "Property 'x' does not exist": you accessed a field in the wrong case. ✅ Only read fields that belong to the matched member; move shared code outside the switch.
- ❌ Forgetting a case silently: a missing branch returns undefined at runtime with no warning. ✅ Add an assertNever(default) branch so the compiler forces completeness.
- ❌ Different discriminant names per member: one member uses kind, another type — TypeScript can't narrow. ✅ Use the same field name across all members.
- ❌ Overlapping literals: two members sharing the same discriminant value collapse into one and break narrowing. ✅ Give every member a unique discriminant value.
Pro Tips
- 💡 Make impossible states impossible: attach data only to the state that owns it (data lives on success only).
- 💡 Always add the never default — it's the cheapest insurance against forgotten cases in the codebase.
- 💡 A boolean can be a discriminant too: { ok: true; value } | { ok: false; error } is the idiomatic Result type.
- 💡 This is the foundation of useReducer and Redux: actions and state are both discriminated unions keyed by type/status.
Mini-Challenge: Result<T>
Build an unwrapResult for a boolean-discriminated Result union — return the value on success, the fallback on failure. Follow the outline, run it, and match the example output.
// 🎯 MINI-CHALLENGE: a Result<T> discriminated union
// A classic "success or failure" union, discriminated by a boolean "ok":
// type Result<T> =
// | { ok: true; value: T }
// | { ok: false; error: string };
//
// Write unwrapResult(result, fallback) that:
// 1. If result.ok is true, returns result.value.
// 2. If result.ok is false, returns the fallback.
// 3. (Add a default branch that throws, mirroring an exhaustiveness check.)
//
// Test with:
// unwrapResult({ ok: true, value: 42 }, -1)
// unwrapResult({ ok: false, error: "boom" }, -1)
// unwrapResult({ ok: true, value: "hello" }, "x")
//
// ✅ Example output:
// 42
// -1
// hello
// your code here🎉 Lesson Complete
- ✅ A shared literal discriminant (kind/type/status) tags each member of the union
- ✅ A switch on the discriminant narrows the value to one member per case
- ✅ Only the matched member's fields are accessible — impossible states are unrepresentable
- ✅ An assertNever default branch makes the compiler enforce exhaustiveness
- ✅ Request state (idle/loading/success/error) and Result (ok/error) are textbook uses
- ✅ This pattern powers useReducer, Redux actions, and parser/AST nodes
Practice quiz
What makes a union 'discriminated'?
- It has more than three members
- All members are objects
- Every member shares a property set to a unique literal type (the discriminant)
- It uses the never type
Answer: Every member shares a property set to a unique literal type (the discriminant). A shared property with a unique literal value per member (kind/type/status) is the discriminant.
Common names for the discriminant field include:
- kind, type, status
- id, key, ref
- name, label, tag only
- value, data, payload
Answer: kind, type, status. Conventional discriminant field names are kind, type, or status.
When you switch on the discriminant, what does TypeScript do inside each case?
- Nothing — narrowing is runtime-only
- Widens it to the full union
- Casts it to any
- Narrows the value to the single member whose discriminant matches
Answer: Narrows the value to the single member whose discriminant matches. Control-flow analysis narrows the value to exactly the matching member in each case.
Inside 'case "circle":' for a shape union, which field is accessible?
- width
- radius
- size
- all of them
Answer: radius. After narrowing to the circle member, only its own fields (like radius) are available.
What is the purpose of the never-based exhaustiveness check?
- To make the compiler error if a new variant is added but a case is forgotten
- To speed up the switch
- To throw at runtime always
- To widen the union
Answer: To make the compiler error if a new variant is added but a case is forgotten. Assigning the value to never (or assertNever) turns a forgotten case into a compile error.
What signature does the exhaustiveness helper typically have?
- function assertNever(x: any): void
- function assertNever(x: unknown): boolean
- function assertNever(x: never): never
- function assertNever(): void
Answer: function assertNever(x: never): never. assertNever(x: never): never errors at compile time if x is not actually never.
Why must the discriminant be a literal type, not a wide 'string'?
- Strings are slower
- Widening to string stops narrowing from working
- Literals use less memory
- string is not allowed in unions
Answer: Widening to string stops narrowing from working. If the discriminant is widened to string, TypeScript can no longer narrow per case.
Which is a textbook use of a discriminated union?
- A single number
- A plain string array
- A boolean flag alone
- Request state: idle | loading | success | error
Answer: Request state: idle | loading | success | error. Modelling request state (idle/loading/success/error) is a classic discriminated-union use.
What is the idiomatic Result type discriminated by a boolean?
- { value } | { error }
- { ok: true; value } | { ok: false; error }
- { status: number }
- { data?: T }
Answer: { ok: true; value } | { ok: false; error }. A boolean 'ok' field discriminates the success ({ ok: true; value }) and failure ({ ok: false; error }) cases.
What goes wrong if two members share the same discriminant value?
- Nothing
- The compiler renames one
- They collapse together and narrowing breaks
- It becomes a tuple
Answer: They collapse together and narrowing breaks. Each member needs a UNIQUE discriminant value; duplicates break narrowing.
Continue this course
- Previous: keyof, typeof & Lookup Types
- Next: Modules & Declaration Files — import/export, type-only imports, and ambient .d.ts declarations
- Quick reference: TypeScript cheat sheet
Frequently asked questions
What makes a union 'discriminated'?
Every member of the union shares a property with a unique LITERAL type — the discriminant (also called a tag), commonly named kind, type, or status. Because each member sets that property to a different literal ('circle' vs 'rect' vs 'square'), checking the property's value tells TypeScript exactly which member you have. Without a shared literal field, a union is just a plain union and the compiler can't narrow it precisely from a single check.
How does narrowing with switch actually work?
When you switch on the discriminant — switch (shape.kind) — TypeScript uses control-flow analysis to narrow shape inside each case to the one member whose discriminant matches that case label. So inside case 'circle': the compiler knows shape is { kind: 'circle'; radius: number } and lets you read shape.radius, while shape.width would be a compile error there. The same works with if (shape.kind === 'circle'). The narrowing is automatic and disappears at runtime.
What is the never-based exhaustiveness check for?
It guarantees you handle every variant. In the default branch you assign the value to a variable of type never (e.g. const _exhaustive: never = shape, or call a function assertNever(shape: never)). If every case is handled, the value is narrowed to never and the assignment is fine. But the moment someone adds a NEW variant to the union and forgets a case, that variant is no longer never, so the assignment fails to compile — turning a forgotten case into a build error instead of a silent runtime bug.
When should I model data as a discriminated union?
Whenever a value can be in one of several mutually exclusive states, each carrying different data. Classic cases: request state (idle | loading | success | error), a Result type (success with a value | failure with an error), Redux/useReducer actions (each action a different shape keyed by type), parsed AST nodes, and form-field kinds. Discriminated unions make the impossible states unrepresentable — you can't have a 'loading' state that also carries error text unless you put it there on purpose.