Discriminated Unions in TypeScript

Discriminated unions (also known as tagged unions or algebraic data types) are one of TypeScript's most powerful features for modeling complex data. They allow you to define a type that could be one of several different shapes, using a single "tag" or "discriminator" to help TypeScript identify exactly which one you are working with. If you've ever struggled with checking if a property exists on an object before using it, discriminated unions are the solution you've been looking for.

Developer Tip: Think of a discriminated union as a way to implement "Pattern Matching" in TypeScript. It allows the compiler to narrow down your logic based on a single, shared property.

 

What are Discriminated Unions?

A discriminated union is a union type where every member shares a common property—the discriminator. This property must be a "literal type" (like a specific string "success" or "error", rather than just string). When you check this property in your code using an if or switch statement, TypeScript’s "Control Flow Analysis" kicks in. It realizes that if the tag matches a specific value, the object must belong to that specific interface, effectively "unlocking" the properties unique to that type.

Common Mistake: Forgetting to use a literal type for your discriminator. If you define your tag as a generic string instead of a specific value like "circle", TypeScript won't be able to narrow the type properly.

 

Key Concepts

  • Discriminator Property: A shared property across all types in the union that uses a unique literal value (string, number, or boolean).
  • Union Type: Combining multiple interfaces or types into one using the pipe (|) symbol.
  • Type Narrowing: The process where TypeScript identifies the specific type within a union based on your logic, ensuring type safety.

 

Example of Discriminated Unions

Imagine you are building a graphic design tool. You need to handle different shapes, but each shape has different dimensions (a circle has a radius, while a square has a side length). Without discriminated unions, you might end up with a messy object full of optional properties.

interface Circle {
  type: "circle"; // The Discriminator
  radius: number;
}

interface Square {
  type: "square"; // The Discriminator
  sideLength: number;
}

// The Discriminated Union
type Shape = Circle | Square;

function calculateArea(shape: Shape): number {
  // TypeScript knows 'type' exists on both interfaces
  if (shape.type === "circle") {
    // Inside this block, TypeScript knows 'shape' is a Circle
    return Math.PI * shape.radius * shape.radius;
  } else {
    // Inside this block, TypeScript knows 'shape' must be a Square
    return shape.sideLength * shape.sideLength;
  }
}

const myCircle: Circle = { type: "circle", radius: 10 };
const mySquare: Square = { type: "square", sideLength: 5 };

console.log(calculateArea(myCircle));  // Output: 314.159
console.log(calculateArea(mySquare));  // Output: 25

Explanation:

  • The Shape type is the union. It says "A shape is either a Circle OR a Square."
  • Both interfaces have the type property. This is our "tag."
  • In calculateArea, when we check shape.type === "circle", TypeScript automatically hides the sideLength property and reveals the radius property because it’s 100% sure we are dealing with a Circle.
Best Practice: Use a switch statement instead of if/else when dealing with three or more types in a union. It’s cleaner and makes it easier to handle "exhaustiveness checking."

 

Advantages of Discriminated Unions

  1. Type Safety: You no longer have to use type assertions (like as Circle) or manual checks (like "radius" in shape).
  2. Readability: Your code clearly shows the different "states" or "variants" your data can take.
  3. Flexibility: Adding a new variant, like a Triangle, is easy. Just add it to the union, and TypeScript will point out exactly where your existing logic needs to be updated.

Example with Complex Types

A real-world use case for this is handling API states. In modern web apps, you usually have three states: Loading, Success, and Error. Discriminated unions make this incredibly easy to manage in a single object.

interface LoadingState {
  status: "loading";
}

interface SuccessResponse {
  status: "success";
  data: string[];
}

interface ErrorResponse {
  status: "error";
  error: Error;
}

type ApiResponse = LoadingState | SuccessResponse | ErrorResponse;

function renderUI(state: ApiResponse) {
  switch (state.status) {
    case "loading":
      return "Loading spinner...";
    case "success":
      // Safe to access .data here
      return `Items: ${state.data.join(", ")}`;
    case "error":
      // Safe to access .error here
      return `Error: ${state.error.message}`;
  }
}
Watch Out: If you add a new state to ApiResponse (like "idle") but forget to update your switch statement, your code might fail silently. See the tip below on how to prevent this.

Explanation:

  • The status property acts as the discriminator.
  • By checking state.status, we avoid runtime errors where we might try to read state.data while the data is still loading.

 

Using Discriminated Unions with Functions

Discriminated unions are excellent for user role management. Different users might have different permissions or profile structures.

interface Admin {
  role: "admin";
  permissions: string[];
}

interface User {
  role: "user";
  username: string;
}

type Person = Admin | User;

function getDisplayName(person: Person): string {
  if (person.role === "admin") {
    // TypeScript knows admins have permissions but no username
    return `Administrator (Permissions: ${person.permissions.length})`;
  } else {
    // TypeScript knows users have a username
    return `User: ${person.username}`;
  }
}
Developer Tip: You can perform "Exhaustiveness Checking" by using the never type. If you assign your variable to a type never in the default case of a switch, TypeScript will throw a compile error if you ever add a new member to the union but forget to handle it.

 

Summary

Discriminated unions in TypeScript provide a structured, safe way to handle variables that can take on different shapes. By including a literal "tag" property, you give the TypeScript compiler the information it needs to protect your code from common "property undefined" errors.

  • Discriminated Union: A union of types linked by a common, literal property (the tag).
  • Type Narrowing: The automatic process where TypeScript narrows down the possibilities within a union based on your code's logic.
  • Flexibility and Safety: They make your code more predictable and easier to refactor, especially in large-scale applications.

Whether you're handling API responses, Redux actions, or UI component states, discriminated unions are an essential tool in every TypeScript developer's toolkit.