Exhaustive checking in TypeScript isn’t about preventing your code from crashing; it’s about preventing your code from lying to you about its own completeness.
Let’s say you’re modeling different types of notifications:
type NotificationType = "email" | "sms" | "push";
interface EmailNotification {
type: "email";
to: string;
subject: string;
body: string;
}
interface SmsNotification {
type: "sms";
phoneNumber: string;
message: string;
}
interface PushNotification {
type: "push";
deviceToken: string;
title: string;
body: string;
}
type Notification = EmailNotification | SmsNotification | PushNotification;
function sendNotification(notification: Notification) {
switch (notification.type) {
case "email":
console.log(`Sending email to ${notification.to}`);
// ... actual email sending logic
break;
case "sms":
console.log(`Sending SMS to ${notification.phoneNumber}`);
// ... actual SMS sending logic
break;
case "push":
console.log(`Sending push to ${notification.deviceToken}`);
// ... actual push notification logic
break;
default:
// What happens if a new notification type is added?
console.error("Unknown notification type!");
}
}
This looks pretty solid. We have a union type Notification and a switch statement that handles each case. But what happens if we later add a new notification type, say, "inApp":
type NotificationType = "email" | "sms" | "push" | "inApp"; // New type added
interface InAppNotification {
type: "inApp";
userId: string;
content: string;
}
type Notification = EmailNotification | SmsNotification | PushNotification | InAppNotification; // Union type updated
If you re-run sendNotification with an InAppNotification object, the switch statement will hit the default case. Our console.error will fire, but more importantly, the code didn’t crash. It just silently failed to send the notification because the switch statement is not exhaustive. TypeScript, by default, doesn’t enforce that all possible cases of a union type are handled in a switch or if/else if chain.
This is where the never type comes in. The never type represents values that will never occur. It’s often used as a return type for functions that always throw an error or never return, but it has a crucial role in exhaustive checking.
The trick is to leverage the fact that TypeScript will only allow you to assign a value of type never to another value of type never. If a union type is not fully handled in a switch statement, the default case will actually receive a value of the remaining union type, not never.
Here’s how to implement it:
function sendNotification(notification: Notification) {
switch (notification.type) {
case "email":
console.log(`Sending email to ${notification.to}`);
break;
case "sms":
console.log(`Sending SMS to ${notification.phoneNumber}`);
break;
case "push":
console.log(`Sending push to ${notification.deviceToken}`);
break;
// NEW: Add the exhaustive check
default:
const _exhaustiveCheck: never = notification;
console.error(`Unhandled notification type: ${(_exhaustiveCheck as any).type}`);
return _exhaustiveCheck; // This line is crucial for type safety
}
}
Now, let’s go back to our scenario where we added InAppNotification but forgot to update sendNotification:
type NotificationType = "email" | "sms" | "push" | "inApp";
// ... InAppNotification interface and updated Notification type
const myInAppNotification: Notification = {
type: "inApp",
userId: "user-123",
content: "You have a new message!"
};
sendNotification(myInAppNotification);
When sendNotification is called with myInAppNotification, the switch statement will execute. It will go through "email", "sms", and "push", none of which match "inApp". It then falls into the default case.
At this point, notification inside the default block is of type InAppNotification (because that’s the only remaining type in the Notification union that wasn’t matched).
The line const _exhaustiveCheck: never = notification; attempts to assign notification (which is InAppNotification) to a variable of type never. This is where TypeScript’s compiler will catch the error:
Type 'InAppNotification' is not assignable to type 'never'.
This compile-time error tells you exactly that your switch statement is not exhaustive. You’ve missed a case.
Once you fix it by adding the inApp case to your switch statement:
function sendNotification(notification: Notification) {
switch (notification.type) {
case "email":
console.log(`Sending email to ${notification.to}`);
break;
case "sms":
console.log(`Sending SMS to ${notification.phoneNumber}`);
break;
case "push":
console.log(`Sending push to ${notification.deviceToken}`);
break;
case "inApp": // Added this case
console.log(`Sending in-app notification to user ${notification.userId}`);
break;
default:
const _exhaustiveCheck: never = notification;
console.error(`Unhandled notification type: ${(_exhaustiveCheck as any).type}`);
return _exhaustiveCheck;
}
}
The assignment const _exhaustiveCheck: never = notification; in the default case will now succeed. Why? Because if all cases ("email", "sms", "push", "inApp") are handled, the only way notification could reach the default block is if it were of a type that doesn’t exist in the Notification union. In other words, it would have to be of type never. Since notification is guaranteed to be one of the defined types, and all defined types are handled, the default case will genuinely never be reached at runtime. Assigning it to never is therefore type-safe.
This pattern is incredibly powerful. It transforms a potential runtime bug (unhandled notification type) into a compile-time error, forcing you to keep your logic in sync with your types. It’s a proactive way to ensure that as your application grows and your union types evolve, you don’t accidentally introduce unhandled scenarios.
The return _exhaustiveCheck; line at the end of the default block is also important. If you ever do hit that default case (which you shouldn’t if exhaustive checking works), returning never signals that this path should logically never complete. It helps with control flow analysis for the function’s return type.
The next concept you’ll naturally run into is how to apply this same exhaustive checking pattern to conditional logic beyond switch statements, such as if/else if chains, and how to make it work seamlessly with asynchronous operations.