Discriminated unions are a powerful way to model states that can be one of several distinct shapes, but the real magic happens when the compiler knows you’ve handled every single one.
Let’s see this in action. Imagine we’re building a simple notification system. Our notifications can be either an error, a success message, or an informational alert.
type Notification =
| { type: 'error'; message: string; code: number }
| { type: 'success'; message: string; timestamp: Date }
| { type: 'info'; message: string; details: string };
function displayNotification(notification: Notification) {
switch (notification.type) {
case 'error':
console.error(`Error ${notification.code}: ${notification.message}`);
break;
case 'success':
console.log(`Success at ${notification.timestamp.toISOString()}: ${notification.message}`);
break;
case 'info':
console.info(`Info: ${notification.message} - ${notification.details}`);
break;
// What happens if we forget a case?
}
}
const errorNotification: Notification = { type: 'error', message: 'File not found', code: 404 };
const successNotification: Notification = { type: 'success', message: 'Operation complete', timestamp: new Date() };
const infoNotification: Notification = { type: 'info', message: 'System update available', details: 'Version 2.1.0' };
displayNotification(errorNotification);
displayNotification(successNotification);
displayNotification(infoNotification);
This looks pretty good, right? We’ve defined our Notification type, and our displayNotification function handles each possible shape. But what if we add a new type of notification later?
// Later, we add a new notification type
type Notification =
| { type: 'error'; message: string; code: number }
| { type: 'success'; message: string; timestamp: Date }
| { type: 'info'; message: string; details: string }
| { type: 'warning'; message: string; severity: 'low' | 'medium' | 'high' }; // New type!
// ... (rest of the code remains the same)
If we try to compile the code above, TypeScript will happily compile it without a single warning. However, our displayNotification function is now broken. If it ever receives a warning notification, it will fall through the switch statement and do nothing, or worse, throw a runtime error if break was omitted and the default case wasn’t handled. This is where exhaustive type narrowing comes in.
The goal is to ensure that when you’re dealing with a discriminated union, the compiler forces you to handle every single possibility. The common pattern to achieve this is to use a switch statement on the discriminant property (in our case, type) and then add a default case that should be unreachable.
Here’s how we make the switch exhaustive:
type Notification =
| { type: 'error'; message: string; code: number }
| { type: 'success'; message: string; timestamp: Date }
| { type: 'info'; message: string; details: string }
| { type: 'warning'; message: string; severity: 'low' | 'medium' | 'high' };
function displayNotification(notification: Notification) {
switch (notification.type) {
case 'error':
console.error(`Error ${notification.code}: ${notification.message}`);
break;
case 'success':
console.log(`Success at ${notification.timestamp.toISOString()}: ${notification.message}`);
break;
case 'info':
console.info(`Info: ${notification.message} - ${notification.details}`);
break;
case 'warning': // Added case for the new type
console.warn(`Warning (${notification.severity}): ${notification.message}`);
break;
default:
// This is the crucial part for exhaustiveness.
// 'notification' here will have the type of *all* possible Notification types *except* those handled above.
// If we've handled all cases, this variable will be of type 'never'.
const _exhaustiveCheck: never = notification;
// TypeScript will complain if _exhaustiveCheck is not of type 'never',
// meaning we missed a case.
console.error(`Unhandled notification type: ${(_exhaustiveCheck as any).type}`);
return _exhaustiveCheck; // Returning 'never' is appropriate here.
}
}
// ... (example usage remains the same)
The default case is where the magic happens. Inside the default block, the notification variable, due to TypeScript’s control flow analysis, is narrowed down to only the types that were not explicitly handled in the preceding case statements. If all possible union members have been covered by case statements, notification will have the type never.
We then assign this notification variable to a variable declared with the never type, like const _exhaustiveCheck: never = notification;. If you forget to add a case for a specific member of the union, that member will still be present in the type of notification within the default block. Assigning a non-never type to a variable of type never will trigger a TypeScript compilation error. This error is your signal that you’ve missed a case and haven’t handled all possibilities.
This technique is incredibly valuable because it makes your code self-documenting and, more importantly, self-correcting. As your data structures evolve and new states are added, the compiler will immediately flag any places where those new states aren’t accounted for, preventing subtle runtime bugs.
The most surprising aspect of this pattern is how the never type, which represents a value that should never occur, becomes your best friend for ensuring correctness. It’s not just about preventing errors; it’s about leveraging the type system to guide development and guarantee that all branches of your logic are robust.
The next step in mastering discriminated unions often involves understanding how to use them effectively with higher-order functions or when dealing with asynchronous operations.