All posts
typescripttypes

TypeScript Discriminated Unions: A Practical Guide for Full-Stack Developers

A practical guide to TypeScript discriminated unions — modeling state machines, API responses, and eliminating impossible states.

SR

Suhail Roushan

August 6, 2026

·
4 min read
·
0 views

TypeScript discriminated unions are the single best tool for making impossible states impossible to represent, and most developers only discover them after shipping a bug caused by loading, error, and data fields that could all technically be true at once.

A discriminated union is a union of object types that share a common literal property — the "discriminant" — which TypeScript uses to narrow the type automatically inside if statements or switch blocks. Instead of a loose object with optional fields that might contradict each other, you get a type where each valid state is explicit and mutually exclusive.

Why Discriminated Unions Matter (and When to Skip Them)

The alternative to a discriminated union is usually a "kitchen sink" interface:

interface RequestState {
  loading: boolean;
  data?: User;
  error?: string;
}

This type technically allows loading: true and error: "failed" at the same time — a state that should never exist but that TypeScript happily accepts. Discriminated unions close that hole entirely.

Skip them for genuinely simple data with no meaningful "state" — a plain User interface doesn't need a discriminant. Reach for them the moment a type represents one of several distinct states.

Getting Started with Discriminated Unions

Model the same request state as a proper discriminated union:

type RequestState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: string };

function render(state: RequestState<User>) {
  switch (state.status) {
    case "idle":
      return "Waiting to start";
    case "loading":
      return "Loading...";
    case "success":
      return state.data.name; // TypeScript knows `data` exists here
    case "error":
      return state.error; // TypeScript knows `error` exists here
  }
}

Notice state.data is only accessible inside the "success" case — TypeScript narrows the union automatically based on the status literal.

Core Discriminated Union Concepts Every Developer Should Know

The discriminant must be a literal type, not string or boolean. status: string won't narrow — TypeScript needs status: "loading" (a specific literal) to distinguish the branches.

Exhaustiveness checking catches missing cases at compile time. Add a never check in the default branch, and TypeScript errors if you add a new state and forget to handle it:

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

function render(state: RequestState<User>) {
  switch (state.status) {
    case "idle": return "Waiting to start";
    case "loading": return "Loading...";
    case "success": return state.data.name;
    case "error": return state.error;
    default: return assertNever(state); // compile error if a case is missing
  }
}

Discriminated unions model API responses cleanly. A common real-world pattern is a result type instead of throwing errors:

type Result<T> = { ok: true; value: T } | { ok: false; error: string };

async function fetchUser(id: string): Promise<Result<User>> {
  try {
    const user = await db.users.findById(id);
    return { ok: true, value: user };
  } catch (err) {
    return { ok: false, error: String(err) };
  }
}

Common Discriminated Union Mistakes and How to Fix Them

Mistake 1: using a boolean flag instead of a literal string discriminant. { success: boolean; data?: T; error?: string } still allows invalid combinations. Fix: convert boolean flags into explicit literal-string states.

Mistake 2: narrowing on the wrong property. If two members of the union share overlapping optional fields, TypeScript can fail to narrow correctly. Fix: make sure every member has the discriminant field with a unique literal value, and nothing else overlaps ambiguously.

Mistake 3: skipping exhaustiveness checks. Without the never check in a default case, adding a new union member later won't produce a compile error anywhere that switches on it — the bug surfaces at runtime instead. Fix: always add the assertNever pattern to switch statements over discriminated unions.

When Should You Use a Discriminated Union?

Any time a value represents one of several mutually exclusive states: loading states, API results, form validation outcomes, WebSocket message types, or Redux-style actions. If you catch yourself writing multiple optional fields that "shouldn't" all be present at once, that's the signal to switch to a discriminated union.

Discriminated Unions in Production

They pair extremely well with Zod for runtime validation — z.discriminatedUnion("status", [...]) gives you the same exhaustiveness guarantees at runtime that TypeScript gives you at compile time, which matters for anything crossing a network boundary like a webhook payload or API response.

Next time you write an interface with more than one optional field, ask whether it's actually several distinct states pretending to be one type — it usually is.

Related posts

Written by Suhail Roushan — Full-stack developer. More posts on AI, Next.js, and building products at suhailroushan.com/blog.

Get in touch