Discriminated Union Types and Exhaustive Type Checking

Table of Contents

  • Introduction
  • What Are Discriminated Union Types?
  • Discriminated Unions with Literal Types
  • Exhaustive Type Checking: What It Is and Why It’s Important
  • Implementing Exhaustive Type Checking in TypeScript
    • Example 1: Basic Discriminated Union with Exhaustive Checking
    • Example 2: Using never for Exhaustive Checking
  • Benefits of Exhaustive Type Checking
  • Conclusion

Introduction

TypeScript is known for its strong static type system, and one of its most powerful features is Union Types. A discriminated union is a pattern in TypeScript where a union of types is distinguished by a common field, often referred to as a discriminant or tag. By using discriminated unions, TypeScript can narrow the types effectively within a type-safe way, making it easier to manage complex data structures.

To complement this, exhaustive type checking ensures that all possible cases within a union are handled, preventing runtime errors and ensuring better code safety. In this article, we will dive into discriminated union types and exhaustive type checking and explore how these concepts improve the robustness of your TypeScript code.


What Are Discriminated Union Types?

A discriminated union is a union type where each type in the union has a unique field (called a discriminant) that can be used to identify the type at runtime. This allows TypeScript to narrow the union type based on the value of this discriminant field.

For example:

interface Circle {
kind: "circle";
radius: number;
}

interface Square {
kind: "square";
size: number;
}

type Shape = Circle | Square;

Here, Shape is a discriminated union that can either be a Circle or a Square. The field kind acts as the discriminant, allowing TypeScript to distinguish between the two types.

Example of Narrowing with Discriminated Unions

In a function that works with the Shape union, TypeScript can narrow the type based on the value of the kind field:

function calculateArea(shape: Shape): number {
if (shape.kind === "circle") {
return Math.PI * shape.radius * shape.radius;
} else if (shape.kind === "square") {
return shape.size * shape.size;
}
// TypeScript knows that all cases have been handled
}

In this case, the type of shape is narrowed based on the value of the kind property, making the function type-safe.


Discriminated Unions with Literal Types

Discriminated unions are often used with literal types (such as string or number) to create types that can only have specific, predefined values. In the example above, the kind field uses a literal type "circle" and "square" to distinguish between Circle and Square.

This pattern works well when you need to represent multiple, distinct states that a value can have, and you want to avoid mixing those states.

For example:

type Result = { status: "success", value: number } | { status: "error", message: string };

function handleResult(result: Result) {
if (result.status === "success") {
console.log("Success! Value:", result.value);
} else {
console.log("Error:", result.message);
}
}

Here, Result is a discriminated union where the status field is a literal type. The function handleResult uses the discriminant status to determine if it’s a success or error result, ensuring type safety.


Exhaustive Type Checking: What It Is and Why It’s Important

Exhaustive type checking ensures that every possible variant of a union type is handled, preventing the possibility of unhandled cases. This is especially important when the union type is large or when there are many possible types involved.

Without exhaustive checking, TypeScript may not give an error if a case is missed, and the missing case would only be discovered at runtime, potentially causing bugs.

Why Exhaustive Checking Is Important:

  • Prevents runtime errors: By ensuring all types are accounted for, you avoid unexpected behavior.
  • Improves code reliability: You know that all potential states are handled.
  • Provides better maintainability: Any new variant added to the union will immediately prompt the developer to handle it.

Implementing Exhaustive Type Checking in TypeScript

Example 1: Basic Discriminated Union with Exhaustive Checking

Let’s say we have a union type that represents the state of a system:

type Status = "loading" | "success" | "error";

interface LoadingState {
status: "loading";
progress: number;
}

interface SuccessState {
status: "success";
data: any;
}

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

type AppState = LoadingState | SuccessState | ErrorState;

Now, we want to handle different AppState in a function:

function handleAppState(state: AppState) {
switch (state.status) {
case "loading":
console.log(`Loading: ${state.progress}%`);
break;
case "success":
console.log("Data loaded successfully:", state.data);
break;
case "error":
console.error("Error:", state.error);
break;
default:
// Exhaustive check - ensures that all possible states are handled
const _exhaustiveCheck: never = state;
throw new Error("Unhandled case: " + _exhaustiveCheck);
}
}

In this function:

  • The switch statement handles all three possible status values: "loading", "success", and "error".
  • The default case is used as an exhaustive check. The _exhaustiveCheck variable is assigned the type never, which ensures that TypeScript will give an error if any other status value is added to AppState without being handled in the switch block.

Example 2: Using never for Exhaustive Checking

The use of never in the default case ensures that all union types are checked. If a new status is added to the AppState type without being handled, TypeScript will throw a compile-time error, alerting you that the switch statement needs to be updated.

// Adding a new status
type Status = "loading" | "success" | "error" | "paused";

// This will trigger a compile-time error
function handleAppState(state: AppState) {
switch (state.status) {
case "loading":
console.log(`Loading: ${state.progress}%`);
break;
case "success":
console.log("Data loaded successfully:", state.data);
break;
case "error":
console.error("Error:", state.error);
break;
case "paused":
console.log("App is paused");
break;
default:
const _exhaustiveCheck: never = state; // Error will occur if this line is reached
throw new Error("Unhandled case: " + _exhaustiveCheck);
}
}

In this updated example, if the Status union type is modified by adding "paused", TypeScript will throw an error because the switch statement is not handling the new case, and the default case’s never type would no longer be valid.


Benefits of Exhaustive Type Checking

  1. Improved safety: Exhaustive checks make sure you handle every possible case in a union type, preventing runtime errors from unhandled types.
  2. Catch errors early: By leveraging TypeScript’s static type system, exhaustive checks ensure that any unhandled union case is caught during development, not at runtime.
  3. Future-proofing: As your types evolve, exhaustive checks make it easier to modify and expand the union types without missing edge cases.

Conclusion

Discriminated union types and exhaustive type checking are essential tools in TypeScript that help you manage complex types and ensure that all possible cases are handled in a type-safe manner. By leveraging a discriminant field in union types, TypeScript can effectively narrow types, and by implementing exhaustive type checking (especially using never), you ensure that your code remains robust and free of unhandled cases.

Whether you’re working with states in an application or modeling complex data structures, these features will help you write more reliable, maintainable, and error-free TypeScript code.