LearnFrenzy
Sign in Take a free test

Union Types and Narrowing · Lesson 10 of 17

Discriminated Unions and Exhaustive Checks

4 min read Updated Nov 8, 2026 Saurabh Samir

A discriminated union is a union of object types that share one literal property – the "tag" – like status: "loading" | "success" | "error". Checking the tag tells TypeScript exactly which shape you have. It's one of the most useful patterns in TypeScript: request states in React, Redux actions, payment results and messages all use it.

In this lesson

  • Building a discriminated union
  • Narrowing with switch
  • Exhaustive checking with never
  • Making impossible states impossible

A discriminated union

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "rectangle"; width: number; height: number }
  | { kind: "triangle"; base: number; height: number };

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":    return Math.PI * shape.radius ** 2;      // shape: circle
    case "rectangle": return shape.width * shape.height;       // shape: rectangle
    case "triangle":  return 0.5 * shape.base * shape.height;  // shape: triangle
  }
}

const shapes: Shape[] = [
  { kind: "circle", radius: 1 },
  { kind: "rectangle", width: 3, height: 4 },
  { kind: "triangle", base: 6, height: 2 },
];
for (const s of shapes) console.log(s.kind, area(s).toFixed(2));

Output

circle 3.14
rectangle 12.00
triangle 6.00

Each member has a kind with a different literal value. Inside each case, TypeScript knows the exact member, so shape.radius is only allowed in the circle branch.

Exhaustive checks with never

What happens when someone adds a new shape later? Make the compiler find every switch that doesn't handle it:

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; side: number }
  | { kind: "hexagon"; side: number };          // newly added

function assertNever(value: never): never {
  throw new Error(`Unhandled case: ${JSON.stringify(value)}`);
}

function perimeter(shape: Shape): number {
  switch (shape.kind) {
    case "circle": return 2 * Math.PI * shape.radius;
    case "square": return 4 * shape.side;
    default:       return assertNever(shape);    // hexagon reaches here
  }
}
console.log(perimeter({ kind: "square", side: 2 }));

Type errors

main.ts(14,39): error TS2345: Argument of type '{ kind: "hexagon"; side: number; }' is not assignable to parameter of type 'never'.

How the never check works

  1. Each case removes a memberafter "circle" and "square", only hexagon is left
  2. In default, shape should be neverif every member was handled, nothing is left
  3. A member is left overpassing it to assertNever(value: never) is a type error
  4. Add the missing casethe error disappears

With the hexagon case added, the code compiles and runs:

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; side: number }
  | { kind: "hexagon"; side: number };

function assertNever(value: never): never {
  throw new Error(`Unhandled case: ${JSON.stringify(value)}`);
}

function perimeter(shape: Shape): number {
  switch (shape.kind) {
    case "circle":  return 2 * Math.PI * shape.radius;
    case "square":  return 4 * shape.side;
    case "hexagon": return 6 * shape.side;
    default:        return assertNever(shape);
  }
}
console.log(perimeter({ kind: "square", side: 2 }), perimeter({ kind: "hexagon", side: 1.5 }));

Output

8 9

Request state: impossible states made impossible

interface Course { title: string; lessons: number }

// Bad: { loading: boolean; data?: Course[]; error?: string } allows loading + error + data at once.
type RequestState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: Course[] }
  | { status: "error"; error: string };

function render(state: RequestState): string {
  switch (state.status) {
    case "idle":    return "Click to load courses";
    case "loading": return "Loading…";
    case "success": return state.data.map((c) => `${c.title} (${c.lessons})`).join(", ");
    case "error":   return `Something went wrong: ${state.error}`;
  }
}

const states: RequestState[] = [
  { status: "idle" },
  { status: "loading" },
  { status: "success", data: [{ title: "TypeScript", lessons: 20 }, { title: "SQL", lessons: 30 }] },
  { status: "error", error: "Network timeout" },
];
for (const s of states) console.log(render(s));

Output

Click to load courses
Loading…
TypeScript (20), SQL (30)
Something went wrong: Network timeout

Why this matters in React

With separate loading, error and data fields, nothing stops "loading and error at the same time" bugs. A discriminated union makes those states unrepresentable, and you can only reach data after checking status === "success". The same pattern types Redux and useReducer actions.

Practice

Try it yourself

  1. Model a payment as { method: "card"; last4: string } | { method: "upi"; vpa: string } | { method: "cod" } and write an exhaustive label().

    Show solution
    type Payment = { method: "card"; last4: string } | { method: "upi"; vpa: string } | { method: "cod" };
    
    function label(p: Payment): string {
      switch (p.method) {
        case "card": return `Card ending ${p.last4}`;
        case "upi":  return `UPI ${p.vpa}`;
        case "cod":  return "Cash on delivery";
        default: {
          const unreachable: never = p;
          return unreachable;
        }
      }
    }
    const all: Payment[] = [{ method: "card", last4: "4242" }, { method: "upi", vpa: "asha@bank" }, { method: "cod" }];
    for (const p of all) console.log(label(p));

    Output

    Card ending 4242
    UPI asha@bank
    Cash on delivery

Quick check

What makes a union "discriminated"?

Every member has a common property with a distinct literal type (the tag).

Why assign to a never variable in default?

If any case is missing, the leftover member isn't never, so the code won't compile.

Why is a union better than optional loading/data/error fields?

It makes impossible combinations (loading + error) unrepresentable.

Summary

  • Discriminated unions share a literal tag; switching on it narrows to the exact member.
  • assertNever / a never variable in default makes switches exhaustive.
  • Use them for request states, actions and results to make invalid states impossible.

Finished this lesson?

Mark it complete to track your progress, then carry on with the next lesson.

Next lesson

Search LearnFrenzy

Esc

Quick links