Stackvora.tech

Discriminated Unions in TypeScript

Discriminated unions, also known as tagged unions or algebraic data types (ADTs), are a powerful pattern in TypeScript for representing values that can…

Discriminated unions, also known as tagged unions or algebraic data types (ADTs), are a powerful pattern in TypeScript for representing values that can take on one of several distinct forms. This pattern enhances type safety, improves code readability, and allows the TypeScript compiler to perform exhaustive checks, catching potential errors at compile time rather than runtime. At its core, a discriminated union combines a union type with a common, literal-typed property (the "discriminant") that TypeScript can use to narrow down the specific type within the union.

This article explores the mechanics of discriminated unions, demonstrates their practical application with concrete examples, and discusses best practices for their implementation, including exhaustive checking and common pitfalls.

The Core Concept: Discriminant Property and Union Types

The foundation of a discriminated union is a union type where each member shares a common property, typically a string or number literal, which serves as the discriminant. When TypeScript encounters a check against this discriminant property, it can intelligently infer the specific type of the object within the union.

Consider a common scenario: representing the result of an operation that can either succeed with a value or fail with an error. Without discriminated unions, one might use optional properties or a generic any type, leading to less robust code.

type OperationResultBad = {
  data?: any;
  error?: string;
}

// Problem: How do we know if 'data' is present or 'error' is present?
// Both could be undefined, or both could hypothetically be present
// in a poorly designed structure.
const success: OperationResultBad = { data: { userId: 1, name: "Alice" } };
const failure: OperationResultBad = { error: "Network timeout" };

With a discriminated union, we introduce a kind (or type, status, etc.) property as the discriminant:

type SuccessResult<T> = {
  kind: 'success';
  payload: T;
  timestamp: number;
};

type ErrorResult = {
  kind: 'error';
  message: string;
  code: number;
};

type OperationResult<T> = SuccessResult<T> | ErrorResult;

function handleResult<T>(result: OperationResult<T>) {
  if (result.kind === 'success') {
    // TypeScript now knows 'result' is SuccessResult<T>
    console.log(`Operation successful at ${new Date(result.timestamp).toLocaleTimeString()}. Payload:`, result.payload);
  } else {
    // TypeScript now knows 'result' is ErrorResult
    console.error(`Operation failed with code ${result.code}:`, result.message);
  }
}

const apiResponseOK: OperationResult<{ userId: number; username: string }> = {
  kind: 'success',
  payload: { userId: 101, username: 'jsmith' },
  timestamp: Date.now()
};

const apiResponseError: OperationResult<any> = {
  kind: 'error',
  message: 'Authentication failed',
  code: 401
};

handleResult(apiResponseOK);
handleResult(apiResponseError);

In this example, the kind property with literal values ('success' and 'error') acts as the discriminant. Inside the if (result.kind === 'success') block, TypeScript narrows the type of result to SuccessResult<T>, making result.payload and result.timestamp safely accessible. Conversely, in the else branch, result is narrowed to ErrorResult, allowing access to result.message and result.code.

Exhaustive Checking with switch Statements and never

One of the most compelling features of discriminated unions is the ability to enforce exhaustive checking, ensuring that all possible cases of the union type are handled. This is particularly effective when combined with switch statements and the never type.

The never type represents a value that should never occur. If TypeScript can statically determine that a certain code path is unreachable because all possible cases of a union have been handled, it can assign the type never to the remaining variable. By intentionally assigning this 'impossible' variable to a type never, we can force the compiler to flag an error if a new case is added to the union but not handled in the switch statement.

type Event =
  | { type: 'LOGIN_SUCCESS'; userId: string; sessionId: string }
  | { type: 'LOGIN_FAILURE'; reason: string; timestamp: number }
  | { type: 'LOGOUT'; userId: string }
  // Imagine adding a new event type later:
  // | { type: 'PROFILE_UPDATE'; userId: string; changes: Record<string, any> };

function handleUserEvent(event: Event) {
  switch (event.type) {
    case 'LOGIN_SUCCESS':
      console.log(`User ${event.userId} logged in. Session: ${event.sessionId}`);
      break;
    case 'LOGIN_FAILURE':
      console.error(`Login failed at ${new Date(event.timestamp).toLocaleString()} due to: ${event.reason}`);
      break;
    case 'LOGOUT':
      console.log(`User ${event.userId} logged out.`);
      break;
    default:
      // This line effectively uses 'never' for exhaustive checking
      // If a new 'type' is added to the Event union, TypeScript will error here.
      const _exhaustiveCheck: never = event;
      throw new Error(`Unhandled event type: ${(_exhaustiveCheck as any).type}`);
  }
}

// Example usage:
handleUserEvent({ type: 'LOGIN_SUCCESS', userId: 'user123', sessionId: 'abc-123' });
handleUserEvent({ type: 'LOGIN_FAILURE', reason: 'Invalid credentials', timestamp: Date.now() });
handleUserEvent({ type: 'LOGOUT', userId: 'user123' });

// If we uncommented 'PROFILE_UPDATE' in 'Event' and didn't add a 'case' for it,
// the 'default' branch would cause a compile-time error because 'event'
// could potentially be 'PROFILE_UPDATE', which is not 'never'.

The const _exhaustiveCheck: never = event; line is crucial. If a new type is added to the Event union (e.g., PROFILE_UPDATE) and no case statement handles it, then event in the default branch will still be of type ProfileUpdateEvent (or a union including it), not never. This mismatch will result in a compile-time error: "Type 'ProfileUpdateEvent' is not assignable to type 'never'". This forces developers to update their handling logic whenever the union structure changes.

Practical Applications and Use Cases

Discriminated unions are highly versatile and find applications across various programming domains:

  • API Responses: Modeling success/error states, or different data shapes depending on a status code.
  • State Management: Representing different states of a UI component (e.g., LoadingState | LoadedState | ErrorState | EmptyState).
  • Event Handling: Defining distinct event types in a system, each with specific payloads (as seen in the handleUserEvent example).
  • ASTs (Abstract Syntax Trees): Representing different node types in a parser or compiler.
  • Configuration Objects: Allowing different configurations based on a chosen mode or strategy.

Example: UI Component State

type DataLoading = {
  status: 'loading';
  progress: number;
};

type DataLoaded<T> = {
  status: 'loaded';
  data: T;
};

type DataError = {
  status: 'error';
  message: string;
  retryAction: () => void;
};

type ComponentState<T> = DataLoading | DataLoaded<T> | DataError;

function renderComponent<T>(state: ComponentState<T>): string {
  switch (state.status) {
    case 'loading':
      return `Loading data... ${state.progress}%`;
    case 'loaded':
      // Assuming 'data' is an object with a 'name' property for this example
      return `Data loaded: ${(state.data as any).name}`;
    case 'error':
      return `Error: ${state.message}. <button onclick="${state.retryAction.name}()">Retry</button>`;
    default:
      const _exhaustiveCheck: never = state;
      throw new Error(`Unknown state: ${(_exhaustiveCheck as any).status}`);
  }
}

const loadingState: ComponentState<any> = { status: 'loading', progress: 50 };
const loadedState: ComponentState<{ id: number; name: string }> = { status: 'loaded', data: { id: 1, name: 'Product A' } };
const errorState: ComponentState<any> = { status: 'error', message: 'Failed to fetch', retryAction: () => console.log('Retrying...') };

console.log(renderComponent(loadingState));
console.log(renderComponent(loadedState));
console.log(renderComponent(errorState));

Trade-offs and Considerations

While powerful, discriminated unions have some considerations:

  • Discriminant Property Naming: Consistently naming the discriminant property (e.g., kind, type, status) across your codebase is crucial for readability and maintainability.
  • Redundancy: Each type in the union must explicitly declare the discriminant property, which can feel repetitive for many small types.
  • Flat Structure: They work best when the differing data is directly part of the union members, rather than nested deeply. For deeply nested structures, you might combine them with other patterns or use type guards.
  • Performance: Type-checking performance for very large unions (hundreds of members) could theoretically be impacted, though this is rarely a practical concern for typical application sizes.

Common Pitfalls

  • Inconsistent Discriminant Values: Accidentally using non-literal types or inconsistent string/number literals for the discriminant property will prevent TypeScript from performing narrowing.
    // Bad: 'status' is a string, not a literal type
    type BadState = { status: string; data: any } | { status: string; error: string };
    // TypeScript cannot narrow 'status' here.
    
  • Missing Discriminant Property: If one of the union members omits the discriminant property, the union ceases to be discriminated, and narrowing will not occur.
  • Not Using Exhaustive Checks: Failing to implement the never type in the default case of a switch statement misses a significant compile-time safety benefit. New cases added to the union will silently go unhandled until runtime errors occur.
  • Over-Complicating Simple Unions: For unions of two types where one has a clear flag, a simple boolean property might suffice without needing a full-blown discriminated union. However, as the number of states grows, discriminated unions quickly become superior.

Discriminated unions are an indispensable tool in the TypeScript developer's arsenal. By leveraging a common literal property and TypeScript's powerful type-narrowing capabilities, they enable the creation of robust, self-documenting, and maintainable code that catches logic errors at compile time, long before they can impact users.

Back to the knowledge base · Ask the AI assistant