TypeScript Discriminated Unions: Patterns That Make Your Code Safer


Discriminated unions are one of TypeScript’s most powerful features for writing safer code, but they’re underused because they’re not immediately obvious. I’ve been using them extensively for the past year, and they’ve eliminated entire classes of bugs that used to slip through to production. Let me show you how they work and why they matter.

The Problem Discriminated Unions Solve

Imagine you’re building a data fetching component. The data might be loading, loaded successfully, or failed with an error. You probably have state that looks like this:

interface State {
  loading: boolean;
  data: User | null;
  error: Error | null;
}

This works, but it allows impossible states. You could have loading: false, data: null, and error: null all at the same time—what does that mean? Or loading: true with data populated—is it loading or loaded?

These impossible states lead to bugs. You’ll write conditionals checking combinations of flags, and you’ll inevitably miss edge cases. Runtime errors happen because your types allowed invalid states that your code didn’t properly handle.

The Discriminated Union Approach

A discriminated union models state as distinct, mutually exclusive cases. Here’s the same data fetching state as a discriminated union:

type State =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: User }
  | { status: 'error'; error: Error };

Now impossible states are impossible. You can’t have status: 'success' without data, or status: 'error' without an error. The type system enforces consistency.

The status property is the “discriminant”—it tells TypeScript which variant you’re dealing with. When you check status, TypeScript narrows the type and knows exactly what other properties are available.

Pattern Matching With Exhaustive Checking

Here’s how you’d render UI based on this state:

function UserProfile({ state }: { state: State }) {
  switch (state.status) {
    case 'idle':
      return <div>Click to load</div>;
    case 'loading':
      return <div>Loading...</div>;
    case 'success':
      return <div>Hello {state.data.name}</div>;
    case 'error':
      return <div>Error: {state.error.message}</div>;
  }
}

TypeScript verifies you’ve handled every case. If you add a new state variant later (say, { status: 'refreshing'; data: User }), TypeScript will complain that your switch statement is incomplete. This is exhaustive checking—the compiler ensures you handle all possibilities.

Without discriminated unions, you’d write nested if statements checking booleans, and there’d be no compile-time guarantee you handled everything. Bugs would slip through.

Modeling Complex Workflows

Discriminated unions really shine when modeling multi-step workflows. Consider a form submission:

type FormState =
  | { status: 'editing'; values: FormValues; errors: ValidationErrors }
  | { status: 'validating'; values: FormValues }
  | { status: 'submitting'; values: FormValues }
  | { status: 'success'; submittedValues: FormValues }
  | { status: 'error'; values: FormValues; error: SubmissionError };

This makes the state machine explicit. You can only be in one state at a time. Transitioning between states requires creating a new variant with the correct shape. The type system prevents nonsensical transitions like going from ‘editing’ directly to ‘success’ without passing through ‘submitting’.

Action Types With Discriminated Unions

If you’re using a reducer pattern (like React’s useReducer), discriminated unions work beautifully for action types:

type Action =
  | { type: 'START_LOADING' }
  | { type: 'LOAD_SUCCESS'; data: User }
  | { type: 'LOAD_ERROR'; error: Error }
  | { type: 'RETRY' };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'START_LOADING':
      return { status: 'loading' };
    case 'LOAD_SUCCESS':
      return { status: 'success', data: action.data };
    case 'LOAD_ERROR':
      return { status: 'error', error: action.error };
    case 'RETRY':
      return { status: 'loading' };
  }
}

Each action type has exactly the payload it needs. TypeScript ensures you don’t dispatch LOAD_SUCCESS without data, or try to access action.data in the START_LOADING case where it doesn’t exist.

API Response Modeling

Discriminated unions are perfect for modeling API responses that can have different shapes:

type ApiResponse<T> =
  | { success: true; data: T }
  | { success: false; error: string; code: number };

async function fetchUser(id: string): Promise<ApiResponse<User>> {
  try {
    const response = await fetch(`/api/users/${id}`);
    if (!response.ok) {
      return {
        success: false,
        error: 'Failed to fetch user',
        code: response.status
      };
    }
    const data = await response.json();
    return { success: true, data };
  } catch (error) {
    return {
      success: false,
      error: 'Network error',
      code: 0
    };
  }
}

// Usage
const result = await fetchUser('123');
if (result.success) {
  console.log(result.data.name); // TypeScript knows data exists
} else {
  console.error(result.error); // TypeScript knows error exists
}

No need for null checks or optional properties. The type tells you exactly what’s available based on the discriminant.

Building Type-Safe Event Systems

If you’re building an event system, discriminated unions ensure type safety across event handlers:

type Event =
  | { type: 'USER_LOGGED_IN'; userId: string; timestamp: number }
  | { type: 'PAGE_VIEWED'; url: string; referrer: string }
  | { type: 'BUTTON_CLICKED'; buttonId: string; label: string };

function trackEvent(event: Event) {
  switch (event.type) {
    case 'USER_LOGGED_IN':
      analytics.track('login', { userId: event.userId });
      break;
    case 'PAGE_VIEWED':
      analytics.track('pageview', { url: event.url });
      break;
    case 'BUTTON_CLICKED':
      analytics.track('click', { button: event.buttonId });
      break;
  }
}

You can’t accidentally pass the wrong data with an event type. TypeScript enforces that each event has the correct payload.

Common Mistakes

Mistake 1: Using optional properties instead of discriminated unions. Don’t do { status: string; data?: User; error?: Error }. Make separate variants.

Mistake 2: Not using literal types for the discriminant. Use status: 'loading' not status: string. Literal types enable TypeScript’s narrowing.

Mistake 3: Forgetting the discriminant property. Every variant needs the same property (status, type, etc.) with different literal values.

Mistake 4: Using unions of interfaces without a discriminant. TypeScript can’t narrow without a discriminant property to check.

When to Use Discriminated Unions

Use discriminated unions when you have:

  • State that can be in multiple mutually exclusive modes
  • API responses with different success/error shapes
  • Multi-step workflows with distinct states
  • Action/event systems with different payload types
  • Any situation where you’re currently using multiple boolean flags

Don’t use them for simple optional properties or when states aren’t mutually exclusive. Not every union needs to be discriminated.

TypeScript Makes This Work

Discriminated unions rely on TypeScript’s control flow analysis and type narrowing. When you check the discriminant in an if statement or switch case, TypeScript narrows the type to that specific variant. This isn’t magic—it’s sophisticated static analysis.

The pattern originated in functional programming languages like OCaml and Haskell (where they’re called “sum types” or “tagged unions”). TypeScript brought them to JavaScript, and they’re one of the features that makes TypeScript genuinely useful for preventing bugs rather than just adding type annotations.

Practical Impact

Since adopting discriminated unions systematically, I’ve eliminated categories of bugs that used to appear regularly. No more “Cannot read property of undefined” because I checked the wrong boolean flag. No more impossible states causing UI glitches. No more runtime errors from missing error handlers.

The code is also more readable. Instead of nested boolean checks, you have explicit state transitions and exhaustive pattern matching. New developers can read a discriminated union definition and immediately understand all possible states.

This is the kind of feature that seems abstract until you use it in production, then you wonder how you built anything complex without it. Start using discriminated unions for your state management, and you’ll catch bugs at compile time that used to crash at runtime. That’s worth the learning curve.