What Are Discriminated Unions in TypeScript?

Harness the power of discriminated unions in TypeScript to write safer, more maintainable, and scalable code with advanced type narrowing.

Introduction

Discriminated unions, also known as tagged unions or algebraic data types, are a powerful feature in TypeScript that allows you to model complex data structures in a type-safe and maintainable way.

They combine union types with literal types and a common discriminant property (usually a string literal) to enable exhaustive type checking and type narrowing.

This feature is particularly useful when you’re handling objects that can take multiple shapes, such as responses from APIs, events in state machines, or Redux-style actions in a TypeScript project.

Why Use Discriminated Unions?

Discriminated unions:

  • Improve code readability and safety
  • Enable better type inference and narrowing
  • Help catch unhandled cases at compile-time
  • Reduce relying on manual type guards and typeof checks

They’re widely used in Node.js backends, front-end frameworks, and libraries like Redux and Zustand to represent different possible states in a clean, robust way.

Syntax of Discriminated Unions

To use discriminated unions, follow three basic steps:

  1. Define interfaces with a common discriminant property (e.g., type).
  2. Assign unique literal values to the discriminant.
  3. Create a union type from these interfaces.
interface Success {
  type: 'success';
  data: string;
}

interface Error {
  type: 'error';
  message: string;
}

interface Loading {
  type: 'loading';
}

// Union type
type Response = Success | Error | Loading;

Using Discriminated Unions in Practice

Let’s use a discriminated union to handle a simulated API response:

function handleResponse(res: Response) {
  switch (res.type) {
    case 'success':
      console.log('Data:', res.data);
      break;
    case 'error':
      console.error('Error:', res.message);
      break;
    case 'loading':
      console.log('Loading...');
      break;
    default:
      // TypeScript will throw an error if a case is unhandled
      const _exhaustiveCheck: never = res;
      return _exhaustiveCheck;
  }
}

This pattern ensures you’ve accounted for all possible types at compile-time, which is extremely valuable in large codebases.

Discriminated Unions vs Type Guards

Discriminated unions work with type guards but offer a more declarative and scalable approach. Instead of writing custom functions to narrow types, TypeScript uses the discriminant value directly for narrowing.

Consider this manual type guard:

function isError(res: Response): res is Error {
  return res.type === 'error';
}

This is valid, but with discriminated unions, we can simplify our logic without needing extra helper functions:

function logResponse(res: Response) {
  if (res.type === 'error') {
    // TypeScript knows res is Error here
    console.error('Error:', res.message);
  } else if (res.type === 'success') {
    console.log('Success:', res.data);
  } else {
    console.log('Still loading...');
  }
}

Thanks to the shared type property, TypeScript automatically narrows the type based on the literal value, which is cleaner and more intuitive than manual guards in most scenarios.

Using as const with Discriminated Unions

When defining objects that you want to use in discriminated unions, it’s often helpful to use the as const assertion. This ensures that TypeScript treats properties like type as literal values instead of widening them to general string types.

I wrote an article about as const, please check it out here.

Without as const, TypeScript may infer a property like type: 'error' as type: string, which breaks type narrowing:

const errorRes = {
  type: 'error',
  message: 'Something went wrong'
}; // type is inferred as { type: string; message: string; }

With as const, you lock in the literal value:

const errorRes = {
  type: 'error',
  message: 'Something went wrong'
} as const;

Now TypeScript infers the type as:

{
  readonly type: 'error';
  readonly message: 'Something went wrong';
}

This is critical when working with discriminated unions, especially when passing such objects as function parameters or using them in switch statements. as const helps TypeScript preserve the literal value needed for proper narrowing and exhaustive checks.

Real-World Use Cases

Here are some scenarios where discriminated unions shine:

1. API Responses

type ApiResponse = { type: 'ok'; data: any } | { type: 'fail'; error: string };

2. Redux/State Management

type Action =
  | { type: 'ADD_TODO'; payload: string }
  | { type: 'REMOVE_TODO'; id: number };

3. UI State Machines

type UiState =
  | { type: 'idle' }
  | { type: 'loading' }
  | { type: 'loaded'; content: string }
  | { type: 'error'; message: string };

Tips for Working with Discriminated Unions

  • Always use literal types for the discriminant.
  • Prefer switch statements for exhaustive handling.
  • Use the never type to catch unhandled cases.
  • Combine with enums if preferred, or use as const as mentioned before.
enum StateType { Idle = 'idle', Loading = 'loading' }

type UiState =
  | { type: StateType.Idle }
  | { type: StateType.Loading };

Conclusion

Discriminated unions are a must-have tool for any TypeScript developer aiming to build robust, scalable, and safe applications. They make code more predictable, enhance type inference, and reduce bugs, especially when dealing with varied object shapes.

Whether you’re building a Node.js API, React front end, or working with complex state management, discriminated unions should be in your TypeScript toolkit.

Want more dev insights like this? Subscribe to get practical tips, tutorials, and tech deep dives delivered to your inbox. No spam, unsubscribe anytime.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top