TypeScript exhaustiveness checking with the never type ensures your switch statements handle every case. Learn to write more robust code that fails at build.

I remember the exact moment I realized my switch statements were a liability. We were refactoring a payment processing module for a client project, adding a new REFUNDED status to our existing PENDING, SUCCESS, and FAILED states. I updated the type definition, ran the build, and felt confident. Two hours later, a user reported a blank screen in the dashboard because one specific UI component wasn't handling the new status.
The compiler didn't complain. It didn't have to. I had a default case in my switch statement that simply returned null, effectively silencing the compiler and hiding the fact that I’d forgotten to update the rendering logic for the new state.
If you've been writing JavaScript for a while, you know the "default" trap. You add a catch-all to keep the linter happy, but that catch-all becomes a graveyard for bugs. We want a way to force the compiler to yell at us whenever we add a new member to a union type but forget to update our logic.
This is where TypeScript exhaustiveness checking shines. By leveraging the never type, we can create a compile-time guarantee that our switch statements cover every possible branch.
Here is the pattern I now use in every production codebase:
TYPESCRIPTtype PaymentStatus = CE9178">'PENDING' | CE9178">'SUCCESS' | CE9178">'FAILED' | CE9178">'REFUNDED'; function getStatusMessage(status: PaymentStatus): string { switch (status) { case CE9178">'PENDING': return CE9178">'Processing...'; case CE9178">'SUCCESS': return CE9178">'Paid!'; case CE9178">'FAILED': return CE9178">'Error occurred.'; // Missing CE9178">'REFUNDED' case default: const _exhaustiveCheck: never = status; return _exhaustiveCheck; } }
In this example, when you add 'REFUNDED' to the PaymentStatus type, the code above will fail to compile. TypeScript will throw an error: Type 'string' is not assignable to type 'never'. It’s telling you exactly what you missed.
The never type represents values that should never occur. By assigning the remaining status variable to a variable of type never, you are asserting that the flow of execution should be impossible to reach.
If you've handled every case, TypeScript narrows the type of status to never by the time it reaches the default block. If you haven't, status will still be a string (or whatever type is left over), and the assignment will fail. It’s elegant, it’s safe, and it’s zero-cost at runtime.
I tried implementing this on a legacy project once, and it was rough. We had huge switch statements that were poorly typed. I first tried to refactor everything at once, but that broke roughly 15 files and took about two days to untangle.
Instead, I recommend taking an iterative approach. Start by tightening your union types using TypeScript Branded Types: Enforcing Domain Integrity at Compile-Time to ensure your data models are sound before you worry about the switch statements. Once your data is predictable, applying the never check becomes trivial.
If you are working with APIs, you might also consider using TypeScript Mapped Types for Effortless API Integration Syncing to ensure that your frontend types stay in sync with your backend responses, which makes these exhaustiveness checks even more powerful.
Q: Can I use this with if/else chains?
A: Yes, it works exactly the same way. You can place the never assignment in the final else block to ensure all conditions are covered.
Q: What if I have a case that I truly don't want to handle?
A: If you have an "unreachable" state, you can explicitly handle it with a comment or a specific error throw, but the never pattern is best reserved for cases where you must handle every possibility.
Q: Does this work with objects? A: It works best with union types of strings or objects (discriminated unions). If your type is a complex object, make sure you have a common literal property to switch on.
One thing to keep in mind: this approach makes your code "brittle" in a good way. Every time you change a type, you’ll be forced to update your logic. While this might feel like an annoyance during a busy sprint, it saves you from debugging "undefined" errors in production.
I’m still experimenting with ways to make this pattern more readable for junior team members. Sometimes, the _exhaustiveCheck variable looks like "magic" to someone who hasn't seen it before. I usually add a small comment above the block explaining why it's there.
There's no silver bullet, but this pattern has saved me from countless late-night hotfixes. Don't fear the compiler error—embrace it as a signal that your code is becoming more robust.
Generics in TypeScript can feel like an academic hurdle, but they pay off when you use them to enforce type safety in API calls and reusable components.
Read more