DEV Community

Cover image for TypeScript Tagged Unions are OP
Jethro Larson
Jethro Larson

Posted on

TypeScript Tagged Unions are OP

Ever struggled with handling multiple object shapes in TypeScript and wished for a more type-safe solution?

If so, you're not alone. Many developers are unaware of the full potential that tagged unions (also known as discriminated unions) offer in TypeScript. This powerful feature can enhance your code's safety, readability, and maintainability. In this article, we'll dive into tagged unions and explore how they can elevate your TypeScript skills.

🏷️ What Are Tagged Unions?

Tagged unions allow you to create types that represent one of several possible shapes, each with a distinguishing property known as the "tag" or "discriminator." This enables TypeScript to narrow down types in conditional checks, ensuring that your code handles all possible cases explicitly.

🤔 Why Should You Care?

Enhanced Type Safety

Tagged unions help catch errors at compile time by ensuring all possible cases are handled. This reduces runtime errors and makes your code more robust.

Clear and Maintainable Code

By explicitly defining the shape of each case, your code becomes more readable and easier to maintain. Future developers (or even future you) will thank you!

Exhaustiveness Checking

TypeScript can warn you if you forget to handle a possible case, ensuring that your code accounts for all scenarios.

📖 Tagged Unions by Example

Consider a scenario where you have different shapes and want to calculate their areas:

// Define interfaces with a discriminant property 'kind'
interface Circle {
  kind: 'circle';
  radius: number;
}

interface Rectangle {
  kind: 'rectangle';
  width: number;
  height: number;
}

interface Triangle {
  kind: 'triangle';
  base: number;
  height: number;
}

// Create a union type of all shapes
type Shape = Circle | Rectangle | Triangle;

// Function to calculate the area based on shape kind
function calculateArea(shape: Shape): number {
  switch (shape.kind) {
    case 'circle':
      return Math.PI * shape.radius ** 2;

    case 'rectangle':
      return shape.width * shape.height;

    case 'triangle':
      return (shape.base * shape.height) / 2;
}

Enter fullscreen mode Exit fullscreen mode

What's Happening Here?

  • Discriminant Property (kind): Each interface includes a kind property with a literal type. This property acts as the tag for the union.
  • Union Type (Shape): Combines all shape interfaces into a single type.
  • Type Narrowing: Inside the switch statement, TypeScript knows exactly which shape it is dealing with based on the kind property.
  • Exhaustiveness Checking: The default case with a never type ensures that if a new shape is added but not handled, TypeScript will produce a compile-time error.

🛠️ Example: State Management

Tagged unions are incredibly useful in state management scenarios, such as representing the various states of an asynchronous operation (e.g., data fetching).

interface LoadingState {
  status: 'loading';
}

interface SuccessState {
  status: 'success';
  data: string;
}

interface ErrorState {
  status: 'error';
  error: string;
}

type AppState = LoadingState | SuccessState | ErrorState;

function renderApp(state: AppState) {
  switch (state.status) {
    case 'loading':
      return 'Loading...';
    case 'success':
      return `Data: ${state.data}`;
    case 'error':
      return `Error: ${state.error}`;
    // default case can be omitted because typescript is making sure all cases are covered!
  }
}
Enter fullscreen mode Exit fullscreen mode

🧐 Why Is This Good?

  • Clear Representation of States: Each interface represents a distinct state of the application, making it easy to understand and manage.

  • Type Safety with Data Access: When the state is 'success', TypeScript knows that state has a data property. Similarly, when the state is 'error', it knows about the error property. This prevents you from accidentally accessing properties that don't exist in a given state.

  • Exhaustiveness Checking: If you add a new state (e.g., EmptyState with status: 'empty'), TypeScript will alert you to handle this new case in the renderApp function.

  • Improved Maintainability: As your application grows, managing different states becomes more manageable. Changes in one part of the code prompt necessary updates elsewhere, reducing bugs.

🎯 Tips for Using Tagged Unions

Consistent Discriminator: Use the same property name (e.g., type, kind, or status) across all types.
Literal Types: Ensure the discriminator property uses literal types ('email', 'sms', etc.) for accurate type narrowing.
Avoid String Enums: Prefer string literal types over enums for discriminators to keep type narrowing straightforward.

👏 Conclusion

Tagged unions are a powerful feature in TypeScript that can help you write safer and more maintainable code. By explicitly handling each possible type, you reduce the chance of unexpected errors and make your code easier to understand.

Give tagged unions a try in your current or next TypeScript project and experience the benefits firsthand!

📚 Further Reading

Top comments (0)