Skip to content
C Codeloom
TypeScript

TypeScript Discriminated Unions

How to model variant types in TypeScript with discriminated unions: design, narrowing, exhaustiveness checks, and real-world patterns.

·5 min read · By Codeloom
Intermediate 9 min read

What you'll learn

  • What a discriminated union is
  • How to design a tag field
  • How narrowing works in switch and if
  • Exhaustiveness with never
  • Real patterns: requests, events, state

Prerequisites

  • Comfortable with basic TypeScript

A discriminated union is the cleanest way TypeScript lets you say “this value is one of several shapes, and you can tell which one by looking at a specific field.” It is the workhorse for modeling API responses, UI states, event types, and anywhere you previously reached for optional fields and if checks. Done well, it gives you compile-time exhaustiveness for free.

What and why

A discriminated union (also called a tagged union or sum type) is a union of object types that all share a literal-typed field, called the discriminant. TypeScript uses that field to narrow the union inside switch and if statements. You get autocomplete and exhaustive checks without runtime overhead.

Why bother instead of one object with optional fields? Because optional fields lie. A User with optional errorMessage does not tell you whether you have a user or an error. A union of { status: 'ok'; user } | { status: 'error'; message } does.

Mental model

The discriminant is a label on the box. The compiler reads the label and lets you open only the matching box.

Response = OkResp | ErrResp
 |
 v
switch (resp.kind) {
case 'ok':    // narrowed to OkResp
case 'error': // narrowed to ErrResp
}
 |
 v
assertNever(resp) // compile error if a case is missing
Narrowing flow

Hands-on example

A fetch result modeled as a union:

type FetchState<T> =
  | { kind: 'idle' }
  | { kind: 'loading' }
  | { kind: 'success'; data: T }
  | { kind: 'error'; message: string };

function render<T>(state: FetchState<T>): string {
  switch (state.kind) {
    case 'idle':    return 'Click to start';
    case 'loading': return 'Loading...';
    case 'success': return `Got ${JSON.stringify(state.data)}`;
    case 'error':   return `Error: ${state.message}`;
  }
}

Inside each case, TypeScript narrows state to the matching member. state.data only exists in 'success', state.message only in 'error'. No casts, no optional chaining.

A UI event modeled the same way:

type Action =
  | { type: 'add'; item: string }
  | { type: 'remove'; id: string }
  | { type: 'clear' };

function reduce(state: string[], action: Action): string[] {
  switch (action.type) {
    case 'add':    return [...state, action.item];
    case 'remove': return state.filter(x => x !== action.id);
    case 'clear':  return [];
  }
}

Exhaustiveness with never

The real power shows when you add a default case that asserts the variable is never. Now adding a new variant turns the compiler into a checklist.

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

function reduce(state: string[], action: Action): string[] {
  switch (action.type) {
    case 'add':    return [...state, action.item];
    case 'remove': return state.filter(x => x !== action.id);
    case 'clear':  return [];
    default:       return assertNever(action);
  }
}

If a coworker adds { type: 'undo' } to Action, the default case fails to compile because action is no longer never there. The compiler points you to every reduce-like function that needs updating.

Designing the discriminant

The discriminant must be a literal type — a specific string, number, or boolean. Common conventions are kind, type, tag, or status. Stay consistent across the codebase.

Avoid overlapping shapes. If two variants both have an optional data field, narrowing on kind still works, but reading .data from kind: 'error' reflects a confused model. Each variant should carry exactly the fields it needs.

// Good: only success carries data
type R = { kind: 'success'; data: User } | { kind: 'error'; message: string };

// Bad: both carry data, defeating the discrimination
type R2 = { kind: 'success'; data?: User } | { kind: 'error'; data?: string };

Common pitfalls

  • Using a non-literal type as the discriminant. kind: string will not narrow. Use 'success', not string.
  • Forgetting default: assertNever(...). Without it, new variants do not flag missing handlers.
  • Mixing discriminated unions with class hierarchies. Pick one approach; both at once is hard to maintain.
  • Narrowing inside complex conditions. Sometimes TypeScript loses the narrowing across function boundaries. Extract a helper and pass the narrowed value in.

Best practices

  • Use a single literal-typed field as the discriminant. Do not encode it across two fields.
  • Pair every discriminated union switch with an exhaustiveness check.
  • Name the discriminant consistently. Pick kind or type and use it everywhere.
  • Co-locate the union definition with the function that consumes it when possible. It keeps changes in one diff.

FAQ

Can the discriminant be a number? Yes, any literal type works: 0, 1, 2 or 'a', 'b'. Strings are most common because they read better.

Can a union member have no extra fields? Yes. { kind: 'idle' } is perfectly valid and very common for “no payload” states.

Does this work with classes? Yes. As long as each class has a literal-typed property, the same narrowing applies. But unions of plain objects are usually simpler.

How is this different from enums? Enums name a set of values. Discriminated unions name a set of shapes. They are complementary; you can even use an enum as the discriminant.