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

💡 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: 4

2. 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 SAVE10

Common Errors (and the fix)

Pro Tips

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

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

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.