4.5 Narrowing to never: The Completeness Pattern
Right, so you’ve met never. It’s TypeScript’s way of telling you that something should literally, physically never happen. It’s the type of a function that throws an error forever, or a variable that can’t possibly have a value. It feels abstract, but it has one killer app that will save your bacon: exhaustive checking.
Think of never as the empty set in mathematics. If a string is a bag full of all possible text, and number is a bag of all possible integers and floats, then never is a completely empty bag. You can assign never to anything because an empty bag can be put inside any other bag (let n: never; let s: string = n; is valid). But you can’t assign anything to never (except another never) because you can’t put a real value into an empty bag without breaking the universe. This property is what makes it so useful for narrowing.
The Completeness Check
The most practical use for never is ensuring you’ve handled every single case in a union type. It’s like having a paranoid, hyper-competent code reviewer living in your type system. You write a function that takes a discriminated union, and you use a switch or if-else chain to handle each member. But what if you add a new variant to the union six months from now and forget to update this function? Without never, that’s a runtime error waiting to happen. With never, it becomes a compile-time error. Let’s see it in action.
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; sideLength: number }
| { kind: "triangle"; base: number; height: number };
function getArea(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.sideLength ** 2;
case "triangle":
return (shape.base * shape.height) / 2;
default:
// Time for the magic trick
const _exhaustiveCheck: never = shape;
return _exhaustiveCheck;
}
}
Right now, this works. The default clause is unreachable because shape has been narrowed to never—we’ve handled all possible cases. The assignment const _exhaustiveCheck: never = shape; is valid precisely because shape is of type never.
Now, watch what happens when some well-meaning developer adds a new shape but forgets to update our function.
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; sideLength: number }
| { kind: "triangle"; base: number; height: number }
| { kind: "hexagon"; sideLength: number }; // New member added
// The getArea function is now OUT OF DATE and we get a glorious error:
// Error: Type '{ kind: "hexagon"; sideLength: number; }' is not assignable to type 'never'.
The compiler tries to assign the new hexagon type to never in the default clause and immediately freaks out. It’s a clear, immediate signal: “Hey, you idiot, you didn’t handle ‘hexagon’ here!” This is infinitely better than the function silently starting to return undefined and causing some obscure bug five layers deep in your application logic.
Why This Works and Best Practices
The mechanism is beautifully simple. In the default clause of a switch that’s supposedly handled all cases, the type of shape should be the empty set, never. We then try to assign this value to a variable explicitly typed as never. This is a perfectly happy, valid operation. The moment the union has a member that isn’t handled, that unhandled member “falls through” to the default clause. Now the type of shape in the default clause is that new type (e.g., { kind: "hexagon"; sideLength: number }), not never. Trying to assign a non-never type to a never variable is a type error, and the compiler throws a fit exactly where you need it to.
A few pro-tips:
- The variable name: I use
_exhaustiveCheckwith a leading underscore. It’s a common convention to signal that this variable exists purely for a type check and is never meant to be actually used. The underscore also tells some linters to chill out about an unused variable. - It must be
never: The critical part is that the variable must be explicitly typed as: never. If you just writeconst _exhaustiveCheck = shape;, the type will be inferred and the check becomes useless. - Works with if/else too: While a switch/case with a
defaultis the most classic and clean approach, you can achieve the same with a chain of if/else statements that end with an else block doing theneverassignment.
This pattern is a hallmark of robust TypeScript code. It leverages the type system not just for documentation, but for active, automated correctness checking. It turns what would be a tedious and error-prone manual task into a simple, automatic compiler error. It’s one of those things that feels like a superpower once you get used to it.