-
Section 1: Getting Started
-
Section 2: Basic Types
-
Section 3: Functions and Objects
-
Section 4: Advanced Types
-
Section 5: Object-Oriented TypeScript
-
Section 6: Working with Modules
-
Section 7: Tooling and Practice
-
Section 8: Type-Level Programming
-
Section 9: TypeScript with Backends
-
Section 10: Testing Typed Code
-
Section 11: Data Validation with Types
-
Section 12: Practical Projects
-
Section 13: Compiler Internals
-
Section 14: Configuration Deep Dive
-
Section 15: Enums, Symbols, and Special Types
-
Section 16: Working with Async Code
-
Section 17: TypeScript and the DOM
-
Section 18: Advanced Generics Practice
-
Section 19: Working with Third-Party Types
-
Section 20: Monorepo and Large-Scale Practices
-
Section 21: Common Pitfalls and Best Practices
-
Section 22: Interview Practice
-
Section 23: Handbook: Narrowing In Depth
-
Section 24: Handbook: Object Types In Depth
-
Section 25: Handbook: Classes In Depth
-
Section 26: Handbook: Modules In Depth
-
Section 27: Handbook: Declaration Files In Depth
-
Section 28: JSX and Namespaces
-
Section 29: Compiler Configuration Reference
-
Section 30: More Practice Exercises
-
Section 31: Handbook: Everyday Types Deep Dive
-
Section 32: Utility Types Full Reference
-
Section 33: Decorators Reference
-
Section 34: Mixins and Advanced OOP Patterns
-
Section 35: Iterators and Generators Typing
-
Section 36: More Type-Level Programming Practice
-
Section 37: TypeScript Ecosystem Tools
-
Section 38: TypeScript with Testing Frameworks
-
Section 39: TypeScript for Library Authors
-
Section 40: More Interview Practice
-
Section 41: Handbook: Functions In Depth
-
Section 42: Handbook: Type Manipulation Deep Dive
-
Section 43: More Real-World Patterns
-
Section 44: TypeScript Release Notes Highlights
-
Section 45: Final Practice and Review
141: The never Type and Exhaustiveness Checking
A few years ago, I was working on a payment processing engine that used a discriminated union to track transaction statuses: Pending, Completed, and Failed. We had a giant switch statement that handled the UI logic for each state. Everything was working perfectly until we decided to add a Refunded state. I updated the type definition, but I completely missed one specific switch block in a legacy notification component. Because that block had a generic default: return null; case, TypeScript didn't complain. The code compiled, we deployed, and users whose payments were refunded simply saw a blank screen with no explanation. It was a classic "silent failure" that could have been prevented if the compiler had just screamed at me the moment I added Refunded to the union.
The Logic of the Impossible
To understand how to prevent that mistake, we have to talk about the never type. You've likely encountered void, which is used for functions that return nothing. never is different; it represents the type of values that cannot exist. It's not just "nothing"—it's an impossibility.
You'll most commonly see never in two places. First, in functions that never return—like a function that always throws an error or an infinite loop. Second, and more importantly for us today, as the result of "narrowing" a union type until there are no possibilities left. If you have a union of string | number and you've already checked if the value is a string and then checked if it's a number, whatever is left in the final else block is, by definition, never.
Forcing the Compiler to Complain
This is where "exhaustiveness checking" comes in. Instead of using a default case that silently swallows errors, we can use the never type to turn a logic error into a compile-time error. The trick is to assign the remaining value to a variable explicitly typed as never.
type PaymentStatus =
| { status: 'pending' }
| { status: 'completed' }
| { status: 'failed' };
function handleStatus(payment: PaymentStatus) {
switch (payment.status) {
case 'pending':
return 'Your payment is processing...';
case 'completed':
return 'Thank you for your purchase!';
case 'failed':
return 'There was an issue with your card.';
default:
// This is the magic part.
// If we've handled every case, 'payment' is now the 'never' type.
const _exhaustiveCheck: never = payment;
return _exhaustiveCheck;
}
}
Right now, this code is happy. But imagine you add | { status: 'refunded' } to the PaymentStatus union. Suddenly, the default block is no longer empty. In that block, payment is now of type { status: 'refunded' }. Since a refunded payment cannot be assigned to the never type, TypeScript will throw a red squiggly line right there: Type '{ status: "refunded" }' is not assignable to type 'never'.
I personally prefer this over default: throw new Error("Unknown status") because the error happens while you're writing the code, not while the user is using the app. It turns your type system into a checklist that ensures you've handled every single edge case you've defined.
📋 Practical Task
Fixing the Broken Notification Dispatcher
You are maintaining a notification system. The NotificationType union was recently expanded to include a 'critical' alert, but the getNotificationColor function wasn't updated. As a result, critical alerts are currently falling through to a default "gray" color, which is a major bug.
Your Task:
Modify the getNotificationColor function to implement exhaustiveness checking using the never type. The goal is to make the TypeScript compiler throw an error in the default block, alerting you that the 'critical' case is missing.
type NotificationType =
| { type: 'info'; message: string }
| { type: 'success'; message: string }
| { type: 'warning'; message: string }
| { type: 'critical'; message: string };
function getNotificationColor(notification: NotificationType): string {
switch (notification.type) {
case 'info':
return 'blue';
case 'success':
return 'green';
case 'warning':
return 'yellow';
default:
// TODO: Implement exhaustiveness checking here
// so that this block errors until 'critical' is handled.
return 'gray';
}
}There are no comments for now.