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 possiblestatus
values:"loading"
,"success"
, and"error"
. - The
default
case is used as an exhaustive check. The_exhaustiveCheck
variable is assigned the typenever
, which ensures that TypeScript will give an error if any otherstatus
value is added toAppState
without being handled in theswitch
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
- Improved safety: Exhaustive checks make sure you handle every possible case in a union type, preventing runtime errors from unhandled types.
- 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.
- 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.