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;
}
}
In this example:
- We define three interfaces:
CreateUserAction,DeleteUserAction, andUpdateUserAction. Each represents a distinct user action. - Crucially, each interface has an
actionTypeproperty with a unique string literal type (e.g.,"CREATE_USER"). This is our discriminant. -
UserActionis a union type combining these three interfaces. - Inside the
handleUserActionfunction, when we use aswitchstatement onaction.actionType, TypeScript automatically narrows the type ofactionwithin eachcaseblock. For instance, in the"CREATE_USER"case,actionis guaranteed to be aCreateUserAction, giving us access toaction.payload.nameandaction.payload.emailwithout 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;
}
}
Here's how this works:
-
SuccessResponse<T>is a generic interface. It has astatusproperty with the literal type"success"and a genericdataproperty of typeT. -
ErrorResponseis a non-generic interface for error cases, withstatus: "error". -
ApiResponse<T>is a discriminated union of these two, usingstatusas the discriminant. - The
fetchData<T>function fetches data and returns anApiResponse<T>, inferringTbased on the usage. - In
getUserProfile, after awaitingfetchData, we checkresult.status. If it's"success", TypeScript automatically knows thatresultis of typeSuccessResponse<UserProfile>, giving us safe access toresult.data.usernameandresult.data.lastLogin. Ifresult.statusis"error", TypeScript correctly infersresultasErrorResponse, allowing access toresult.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.
In this snippet:
- The
processNotificationfunction uses aswitchstatement overevent.type. - The
defaultcase includesconst _exhaustiveCheck: never = event;. This is a powerful pattern. - If we were to add a new
typetoNotificationEvent(e.g.,"push") but forget to add a correspondingcasein theswitchstatement, TypeScript would then seeeventin thedefaultblock as potentially being of type{ type: "push", ... }. Sinceeventis no longer of typenever(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 generalstringornumber. 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
nevertype for exhaustiveness checking can lead to runtime errors when new union members are added but not handled inswitchstatements. 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)