Mastering Exhaustiveness in TypeScript with never: Why It Matters and How to Do It Right

Mastering Exhaustiveness in TypeScript with never: Why It Matters and How to Do It Right
Photo by Luana da Silva / Unsplash

When writing robust TypeScript code, one crucial aspect that often gets overlooked is exhaustiveness in switch statements. Many developers, especially when working with union types, risk leaving out cases unintentionally. This isn’t just a theoretical concern—it can lead to unhandled runtime behavior and silent bugs that slip through testing. Fortunately, TypeScript's never type gives us a powerful tool to enforce completeness.

Let’s break this down.


The Core Problem

Imagine you define a simple union type:

type Fruit = "apple" | "banana" | "orange";

Now, you write a switch statement to handle these:

function describeFruit(fruit: Fruit) {
    switch (fruit) {
        case "apple":
            return "An apple a day keeps the doctor away.";
        case "banana":
            return "Bananas are rich in potassium.";
        case "orange":
            return "Oranges are full of vitamin C.";
    }
}

Looks good, right? But what if, a month later, you expand your Fruit type to:

type Fruit = "apple" | "banana" | "orange" | "pear";

You might forget to update your switch. TypeScript won’t warn you, and the pear case will fall through without a match. The function will return undefined, potentially causing subtle bugs downstream.


The Elegant Solution: never

TypeScript's never type represents a value that should never occur. If you use it cleverly, you can force TypeScript to check for completeness.

Here’s how:

function describeFruit(fruit: Fruit) {
    switch (fruit) {
        case "apple":
            return "An apple a day keeps the doctor away.";
        case "banana":
            return "Bananas are rich in potassium.";
        case "orange":
            return "Oranges are full of vitamin C.";
        default:
            return assertNever(fruit);
    }
}

function assertNever(x: never): never {
    throw new Error(`Unexpected fruit: ${x}`);
}

In this pattern:

  • assertNever accepts a value of type never.
  • If fruit is not handled completely, its type in default will not be never.
  • TypeScript will emit a compile-time error, alerting you to the missing case (like pear).

This is often called exhaustiveness checking, and it’s a vital practice for maintainability and safety.


Why You Should Care

  • Avoid Silent Failures: In a growing codebase, union types often expand. Without exhaustive checks, you might miss handling new cases—introducing hard-to-debug runtime errors.
  • TypeScript Becomes Your Ally: Leveraging never turns TypeScript into a powerful safety net, catching missing cases at compile time.
  • Maintain Future Scalability: As your types evolve, exhaustive switch statements ensure that every possible value gets handled explicitly.

Beyond switch: Using never with if-else or Function Overloads

While switch is a natural fit, you can apply the same concept with if-else chains:

function handleShape(shape: Shape) {
    if (shape.kind === "circle") {
        // ...
    } else if (shape.kind === "square") {
        // ...
    } else {
        return assertNever(shape);  // Compile-time check for exhaustiveness
    }
}

Or even with function overloads or discriminated unions, where exhaustive checking guarantees correct logic.


Common Pitfalls to Watch Out For

  • Forgetting default or assertNever: If you omit the catch-all, TypeScript can’t enforce completeness. Always include it.
  • Loose Type Definitions: If you define your type as string instead of a precise union, TypeScript can’t help. Be explicit with your union types.
  • Refactoring Without Updating Types: When you refactor code (e.g., changing enum values or union members), ensure you update all switch statements that depend on them.

Finally

Exhaustive checking with TypeScript’s never type is a simple yet profound technique. It turns potentially fragile switch statements into rock-solid, future-proof code. By adopting this pattern, you’re not just writing TypeScript—you’re embracing its type safety to the fullest.

Safer Code
No Silent Bugs
Compile-Time Protection

If you’re serious about writing maintainable, error-resistant TypeScript, embrace never today.

Support Us