Discriminated Unions

The Single Most Useful Pattern in TypeScript

Discriminated Unions

A union of objects with a shared literal "tag" field. Switching on that field narrows the whole shape — type-safe variants.

5 min read Level 2/5 #typescript#discriminated-union#narrowing
What you'll learn
  • Author a discriminated union
  • Narrow on the discriminator
  • Use `never` for exhaustive checks

A discriminated union is a union of object types where each shares a single literal “tag” field. Switching on the tag narrows the rest of the object. This is the most useful TS pattern you’ll learn.

The Shape

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

function area(s: Shape): number {
  switch (s.kind) {
    case "circle":
      return Math.PI * s.radius ** 2;          // s.radius is in scope
    case "square":
      return s.side ** 2;                       // s.side is in scope
    case "triangle":
      return 0.5 * s.base * s.height;
  }
}

Inside each case, TS narrows s to that specific variant. You access only the fields that exist for that case.

Why It Beats Optional Fields

Compare:

// ✗ Loose — every field is optional
type Shape = {
  kind: "circle" | "square" | "triangle";
  radius?: number;
  side?: number;
  base?: number;
  height?: number;
};

You can’t reliably tell which fields go with which kind. A circle with a side? Compiler allows it. With a discriminated union, illegal shapes don’t compile.

Common Names

The discriminator field is conventionally type, kind, tag, status, or similar:

type Event =
  | { type: "click"; x: number; y: number }
  | { type: "key"; key: string };

type Result<T> =
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: Error };

Exhaustive Checks With never

A pattern: assign the un-narrowed value to a never parameter at the end. If you add a new variant and forget to handle it, the compiler complains:

function area(s: Shape): number {
  switch (s.kind) {
    case "circle":   return Math.PI * s.radius ** 2;
    case "square":   return s.side ** 2;
    case "triangle": return 0.5 * s.base * s.height;
    default:
      const _exhaustive: never = s;   // ✗ if you forgot a case
      throw new Error(`Unhandled: ${_exhaustive}`);
  }
}

In every handled case, s is one specific variant. By default, s should be never. If you add { kind: "hexagon"; ... } without handling it, the assignment fails — TS catches the missing case at compile time.

The Result Pattern

Discriminated unions model “this or that” answers cleanly:

type ApiResult<T> =
  | { ok: true; data: T }
  | { ok: false; error: string };

async function fetchUser(id: string): Promise<ApiResult<User>> {
  try {
    const res = await fetch(`/api/users/${id}`);
    if (!res.ok) return { ok: false, error: `HTTP ${res.status}` };
    return { ok: true, data: await res.json() };
  } catch (e) {
    return { ok: false, error: String(e) };
  }
}

const result = await fetchUser("1");
if (result.ok) {
  console.log(result.data.name);   // ✓ data exists in this branch
} else {
  console.log(result.error);        // ✓ error exists in this branch
}

A cleaner alternative to throwing — errors and successes both have types.

End of Chapter

That wraps the type system essentials. Next chapter: functions and inference in depth.

Function Types →