DEV Community

Kai Thorne
Kai Thorne

Posted on

TypeScript Discriminated Unions: The Pattern That Made My Error Handling Click

I spent three hours debugging a TypeScript error once. The error message was something like "Property 'statusCode' does not exist on type 'APIResponse'." It turned out I was checking the wrong field on the wrong shape. The worst part? The logic was technically correct — just impossible for the type checker to verify because everything was a wide union.

That's when discriminated unions finally clicked for me.

The Problem: Truthiness-Based Narrowing Is Brittle

Here's the kind of code I used to write everywhere:

type APIResponse = 
  | { data: User[]; error: null }
  | { data: null; error: string }
  | { data: null; error: null };
Enter fullscreen mode Exit fullscreen mode

Looks fine on the surface. But without a shared literal field on every variant — a discriminant — you end up narrowing by truthiness:

function handleResponse(res: APIResponse) {
  // TypeScript narrows this via truthiness — brittle
  if (res.data) {
    // res is { data: User[]; error: null }
    console.log(res.data.length);
  }
  if (res.error) {
    // res is { data: null; error: string }
    console.log(res.error.toUpperCase());
  }
}
Enter fullscreen mode Exit fullscreen mode

This works until data is an empty array ([] is truthy) or error is null in one variant but undefined in another. Your narrowing silently breaks and you're back to runtime bugs. There's also no clean way to represent an "idle" or "loading" state without adding more overlapping nullable fields.

The Fix: A Single Literal Discriminant Key

The fix is one field — conventionally called kind, type, or status — that every variant shares with a unique literal string value:

type APIResponse = 
  | { kind: "success"; data: User[] }
  | { kind: "error"; statusCode: number; message: string }
  | { kind: "loading" }
  | { kind: "idle" };
Enter fullscreen mode Exit fullscreen mode

Now each variant has a single, non-nullable, non-overlapping field. TypeScript narrows the entire object when you check that one field:

function handleResponse(res: APIResponse) {
  switch (res.kind) {
    case "idle":
      return null; // res is { kind: "idle" }
    case "loading":
      return "<loading>"; // res is { kind: "loading" }
    case "success":
      return res.data.map(u => u.name).join(", "); // res is { kind: "success"; data: User[] }
    case "error":
      return `Error ${res.statusCode}: ${res.message}`; // res is { kind: "error"; statusCode: number; message: string }
  }
}
Enter fullscreen mode Exit fullscreen mode

Every case branch is fully typed. No truthiness traps. If you add a new variant and forget to handle it, TypeScript's switch exhaustiveness check (with never) catches it at compile time.

Real-World: Fetch Handler That Actually Works

Here's the pattern I use daily now — a type-safe fetch wrapper:

type FetchState<T> =
  | { kind: "idle" }
  | { kind: "loading" }
  | { kind: "success"; data: T; cachedAt: Date }
  | { kind: "error"; message: string; statusCode: number; retryAfter?: number };

function isStale<T>(state: FetchState<T>): boolean {
  if (state.kind !== "success") return true;
  // state is narrowed to { kind: "success"; data: T; cachedAt: Date }
  return Date.now() - state.cachedAt.getTime() > 60_000;
}
Enter fullscreen mode Exit fullscreen mode

The beauty is that adding or removing a field from one variant never breaks the others. You change the success shape to add cachedAt — every case "success" branch immediately knows about it. No hunting through fifty if (res.data) checks wondering which variant they belong to.

The Bonus: Exhaustive Matching

Pair discriminated unions with a never-check helper and you get compile-time guarantees:

function assertNever(x: never): never {
  throw new Error(`Unexpected value: ${x}`);
}

function renderState<T>(state: FetchState<T>) {
  switch (state.kind) {
    case "idle": return "Click to load";
    case "loading": return "Loading...";
    case "success": return `Got ${(state.data as any[]).length} items`;
    case "error": return `Error: ${state.message}`;
    default: return assertNever(state); // <-- TypeScript error here if we missed a variant
  }
}
Enter fullscreen mode Exit fullscreen mode

If someone adds a "refreshing" variant to FetchState, this function won't compile until you handle it.

When I Reach For This Pattern

I use discriminated unions everywhere now — API state, form state, navigation state, WebSocket messages, and especially Redux actions. Anywhere I have a value that can be in multiple distinct modes, I reach for a discriminated union first.

It saved me from that three-hour debugging session and probably a dozen more since. The upfront cost of writing out the types is minimal. The downstream savings in "oh, that's what this value is supposed to look like" are enormous.

Try it next time you write | { type: string } — make it a literal and watch your switch statements come alive.

Top comments (0)