TypeScript Discriminated Unions: Master Type-Safe Data Handling

Discriminated unions are one of TypeScript’s most powerful features for creating type-safe, maintainable code. By providing a way to narrow down types based on specific properties, they help prevent runtime errors and make your code more predictable. Let’s dive deep into how they work and how to use them effectively.

Table of Contents

What are Discriminated Unions?

A discriminated union (also known as a tagged union) is a type that uses a common property to differentiate between different possible shapes an object can have. This common property is called the discriminant.

type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'square'; sideLength: number }
  | { kind: 'rectangle'; width: number; height: number };
Code language: JavaScript (javascript)

In this example, kind is our discriminant property, helping TypeScript determine which shape we’re working with.

Why Use Discriminated Unions?

Discriminated unions provide several key benefits:

  1. Type Safety: TypeScript can verify at compile-time that you’re handling all possible cases
  2. Better IDE Support: Get excellent autocomplete and type inference
  3. Cleaner Code: Avoid complex type casting and type guards
  4. Maintainable Code: Makes it easier to add new variants in the future

Creating Discriminated Unions

Let’s look at a practical example of creating and using discriminated unions:

// Define our base types
type Success = {
  status: 'success';
  data: string;
};

type Loading = {
  status: 'loading';
};

type Error = {
  status: 'error';
  message: string;
};

// Combine them into a discriminated union
type RequestState = Success | Loading | Error;

// Function that handles different states
function handleRequestState(state: RequestState): string {
  switch (state.status) {
    case 'success':
      return `Success: ${state.data}`;
    case 'loading':
      return 'Loading...';
    case 'error':
      return `Error: ${state.message}`;
  }
}
Code language: JavaScript (javascript)

Type Narrowing with Discriminated Unions

One of the most powerful aspects of discriminated unions is automatic type narrowing:

function processShape(shape: Shape): number {
  switch (shape.kind) {
    case 'circle':
      // TypeScript knows shape is Circle here
      return Math.PI * shape.radius ** 2;
    case 'square':
      // TypeScript knows shape is Square here
      return shape.sideLength ** 2;
    case 'rectangle':
      // TypeScript knows shape is Rectangle here
      return shape.width * shape.height;
  }
}
Code language: JavaScript (javascript)

Best Practices for Discriminated Unions

1. Use Literal Types for Discriminants

type ValidDiscriminant = {
  kind: 'valid'; // Literal type
  data: string;
};

// Avoid using non-literal types
type InvalidDiscriminant = {
  kind: string; // Too broad!
  data: string;
};
Code language: JavaScript (javascript)

2. Keep Discriminant Names Consistent

// Good - consistent discriminant name 'type'
type UserEvent =
  | { type: 'login'; userId: string }
  | { type: 'logout'; userId: string }
  | { type: 'register'; email: string };

// Bad - mixing different discriminant names
type UserAction =
  | { kind: 'login'; userId: string }
  | { type: 'logout'; userId: string }
  | { action: 'register'; email: string };
Code language: JavaScript (javascript)

3. Use Exhaustiveness Checking

function assertNever(x: never): never {
  throw new Error('Unexpected object: ' + x);
}

function handleShape(shape: Shape): number {
  switch (shape.kind) {
    case 'circle':
      return Math.PI * shape.radius ** 2;
    case 'square':
      return shape.sideLength ** 2;
    case 'rectangle':
      return shape.width * shape.height;
    default:
      return assertNever(shape);
  }
}
Code language: JavaScript (javascript)

Advanced Patterns

Combining with Generic Types

type Result<T> =
  | { status: 'success'; data: T }
  | { status: 'error'; error: string };

function processResult<T>(result: Result<T>): T | null {
  if (result.status === 'success') {
    return result.data;
  } else {
    console.error(result.error);
    return null;
  }
}
Code language: JavaScript (javascript)

Using with Classes

abstract class Animal {
  abstract type: string;
}

class Dog extends Animal {
  type = 'dog' as const;
  bark() {
    return 'Woof!';
  }
}

class Cat extends Animal {
  type = 'cat' as const;
  meow() {
    return 'Meow!';
  }
}

function makeAnimalSound(animal: Dog | Cat) {
  if (animal.type === 'dog') {
    return animal.bark();
  } else {
    return animal.meow();
  }
}
Code language: PHP (php)

Common Pitfalls and Solutions

1. Forgetting to Handle All Cases

// TypeScript will error if we don't handle all cases
function incorrectHandler(state: RequestState): string {
  if (state.status === 'success') {
    return state.data;
  }
  // Error: Not all code paths return a value
}
Code language: JavaScript (javascript)

2. Using Non-Literal Types for Discriminants

// Bad: Using non-literal types
type BadShape = {
  kind: string; // Too broad!
  data: any;
};

// Good: Using literal types
type GoodShape = {
  kind: 'circle' | 'square';
  data: any;
};
Code language: JavaScript (javascript)

Conclusion

Discriminated unions are a fundamental feature of TypeScript that enables you to write more type-safe and maintainable code. They work especially well with pattern matching and exhaustiveness checking, making it easier to handle complex data structures with confidence.

By following the best practices outlined in this guide and understanding common pitfalls, you can effectively use discriminated unions to improve your TypeScript applications. Remember to always use literal types for discriminants, maintain consistent naming, and leverage exhaustiveness checking for maximum type safety.

If you’re interested in learning more about TypeScript’s type system, check out our guide on TypeScript Type Guards for additional type-safety patterns.

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.

Share via
Copy link
Powered by Social Snap