DEV Community

Manas Joshi
Manas Joshi

Posted on

TypeScript Discriminated Unions: Advanced Type Narrowing

TypeScript Discriminated Unions: Advanced Type Narrowing

Handling data that can take multiple distinct shapes is a common challenge in application development. Whether it's different event types, varied API responses, or distinct UI states, ensuring type safety when working with these variations can become complex. TypeScript's discriminated unions provide a powerful and elegant solution, allowing you to narrow down types based on a shared literal property.

This technique combines union types with a special kind of type guard: a property common to all members of the union, but with a different literal type in each member. This shared literal property is known as the "discriminant," and TypeScript uses it to infer the specific type within the union, enabling precise type narrowing.

Understanding the Core Concept

At its heart, a discriminated union is a union of types (usually interfaces or type aliases) that share a common field, and that common field has a string literal type (or sometimes a number or boolean literal type). When you check the value of this common field, TypeScript becomes smart enough to know which specific type from the union you're dealing with.

Consider an application that processes various user actions. Each action might have different associated data, but they all share an actionType property.

interface CreateUserAction {
  actionType: "CREATE_USER";
  payload: { name: string; email: string; };
}

interface DeleteUserAction {
  actionType: "DELETE_USER";
  payload: { userId: string; };
}

interface UpdateUserAction {
  actionType: "UPDATE_USER";
  payload: { userId: string; newEmail?: string; newName?: string; };
}

type UserAction = CreateUserAction | DeleteUserAction | UpdateUserAction;

function handleUserAction(action: UserAction) {
  switch (action.actionType) {
    case "CREATE_USER":
      console.log(`Creating user: ${action.payload.name} (${action.payload.email})`);
      // TypeScript knows 'action' is CreateUserAction here
      break;
    case "DELETE_USER":
      console.log(`Deleting user with ID: ${action.payload.userId}`);
      // TypeScript knows 'action' is DeleteUserAction here
      break;
    case "UPDATE_USER":
      console.log(`Updating user with ID: ${action.payload.userId}`);
      // TypeScript knows 'action' is UpdateUserAction here
      if (action.payload.newName) {
        console.log(`New name: ${action.payload.newName}`);
      }
      if (action.payload.newEmail) {
        console.log(`New email: ${action.payload.newEmail}`);
      }
      break;
    default:
      // We'll explore exhaustiveness checking next
      break;
  }
}
Enter fullscreen mode Exit fullscreen mode

In this example:

  • We define three interfaces: CreateUserAction, DeleteUserAction, and UpdateUserAction. Each represents a distinct user action.
  • Crucially, each interface has an actionType property with a unique string literal type (e.g., "CREATE_USER"). This is our discriminant.
  • UserAction is a union type combining these three interfaces.
  • Inside the handleUserAction function, when we use a switch statement on action.actionType, TypeScript automatically narrows the type of action within each case block. For instance, in the "CREATE_USER" case, action is guaranteed to be a CreateUserAction, giving us access to action.payload.name and action.payload.email without type assertions.

Leveraging Generics with Discriminated Unions

Discriminated unions become even more powerful when combined with generics, especially in scenarios like handling generic API responses where the data payload can vary significantly based on the response status.

interface SuccessResponse<T> {
  status: "success";
  data: T;
}

interface ErrorResponse {
  status: "error";
  message: string;
  code?: number;
}

type ApiResponse<T> = SuccessResponse<T> | ErrorResponse;

async function fetchData<T>(url: string): Promise<ApiResponse<T>> {
  const response = await fetch(url);
  if (!response.ok) {
    const errorData = await response.json();
    return { status: "error", message: errorData.message || "Unknown error" };
  }
  const data: T = await response.json();
  return { status: "success", data };
}

interface UserProfile {
  id: string;
  username: string;
  lastLogin: string;
}

async function getUserProfile(userId: string) {
  const result = await fetchData<UserProfile>(`/api/users/${userId}`);

  if (result.status === "success") {
    // TypeScript knows 'result' is SuccessResponse<UserProfile>
    console.log(`User ${result.data.username} last logged in: ${result.data.lastLogin}`);
    return result.data;
  } else {
    // TypeScript knows 'result' is ErrorResponse
    console.error(`Failed to fetch user profile: ${result.message}`);
    return null;
  }
}
Enter fullscreen mode Exit fullscreen mode

Here's how this works:

  • SuccessResponse<T> is a generic interface. It has a status property with the literal type "success" and a generic data property of type T.
  • ErrorResponse is a non-generic interface for error cases, with status: "error".
  • ApiResponse<T> is a discriminated union of these two, using status as the discriminant.
  • The fetchData<T> function fetches data and returns an ApiResponse<T>, inferring T based on the usage.
  • In getUserProfile, after awaiting fetchData, we check result.status. If it's "success", TypeScript automatically knows that result is of type SuccessResponse<UserProfile>, giving us safe access to result.data.username and result.data.lastLogin. If result.status is "error", TypeScript correctly infers result as ErrorResponse, allowing access to result.message.

Exhaustiveness Checking with never

A critical benefit of discriminated unions is the ability to enforce exhaustiveness. This means ensuring that you've handled every possible variant in the union. If you forget a case, TypeScript can alert you. This is typically done by assigning the unhandled value to a variable of type never.

interface NotificationEvent {
  type: "email" | "sms" | "push";
  timestamp: number;
  payload: any;
}

function processNotification(event: NotificationEvent) {
  switch (event.type) {
    case "email":
      console.log("Processing email notification");
      // ... specific email logic
      break;
    case "sms":
      console.log("Processing SMS notification");
      // ... specific SMS logic
      break;
    // What if a new type 'push' is added later and we forget to update this?
    default:
      // TypeScript will error if 'event' is not 'never' here,
      // meaning there's an unhandled case in the switch statement.
      const _exhaustiveCheck: never = event;
      throw new Error(`Unhandled notification type: ${_exhaustiveCheck}`);
  }
}

// Example: Adding 'push' to NotificationEvent type without updating processNotification
// type NotificationEvent = { type: "email" | "sms" | "push"; timestamp: number; payload: any; }
// If you remove the 'push' case from the switch, the '_exhaustiveCheck' line will error,
// because 'event' (which could be { type: "push", ... }) cannot be assigned to 'never'.
// This forces you to add the 'push' case to your switch statement.
Enter fullscreen mode Exit fullscreen mode

In this snippet:

  • The processNotification function uses a switch statement over event.type.
  • The default case includes const _exhaustiveCheck: never = event;. This is a powerful pattern.
  • If we were to add a new type to NotificationEvent (e.g., "push") but forget to add a corresponding case in the switch statement, TypeScript would then see event in the default block as potentially being of type { type: "push", ... }. Since event is no longer of type never (it could be "push"), TypeScript will throw a compilation error, reminding us to handle the new case. This ensures our logic is always complete for all defined types.

Common Mistakes and Gotchas

  • Missing or Mismatched Discriminant Property: All members of the union must have the same discriminant property name. Forgetting to include it, or spelling it differently in one of the interfaces, will prevent TypeScript from recognizing the union as discriminated.

    // Incorrect: 'kind' vs 'type'
    interface Foo { kind: "foo"; } 
    interface Bar { type: "bar"; } 
    type MyUnion = Foo | Bar; // Not a discriminated union
    
  • Non-Literal Discriminant Types: The discriminant property must be a string literal, number literal, or boolean literal type (e.g., "success", 1, true), not a general string or number. If it's a general type, TypeScript cannot use it for narrowing.

    // Incorrect: 'status' is a general string
    interface Success { status: string; data: any; }
    interface Failure { status: string; error: string; }
    type Result = Success | Failure; // Not a discriminated union for narrowing
    
  • Forgetting Exhaustiveness Checks: While optional, not using the never type for exhaustiveness checking can lead to runtime errors when new union members are added but not handled in switch statements. Always consider adding this pattern for robustness in critical functions.

Key Takeaways

Discriminated unions are an indispensable tool in TypeScript for managing complex data structures with varying shapes. They enable:

  • Enhanced Type Safety: Guarantees that you're accessing properties only when they are known to exist on the specific type.
  • Improved Code Readability: Makes intent clear by explicitly differentiating between data variations.
  • Robustness: With exhaustiveness checking, you're prompted to update your logic whenever new types are introduced into a union.
  • Better Developer Experience: Reduces the need for tedious type assertions and provides intelligent autocomplete within narrowed blocks.

By consistently applying discriminated unions, you can write more resilient, maintainable, and understandable TypeScript code, especially when dealing with the dynamic nature of real-world data.

Conclusion

Mastering discriminated unions is a significant step towards writing advanced, type-safe TypeScript. They provide a structural and reliable way to handle conditional types, moving beyond simple instanceof or typeof checks to deep, semantic type narrowing. Integrate this pattern into your projects to build more robust and error-resistant applications. Dive into your codebase and identify areas where discriminated unions could bring clarity and safety today!

Top comments (0)