DEV Community

Kai Thorne
Kai Thorne

Posted on

Mastering TypeScript's `never` Type: Exhaustive Checks, Conditional Types, and Real Patterns

If you've been writing TypeScript for a while, you've probably seen never in error messages or type definitions and wondered what it actually does. It's not just an obscure edge case — never is one of the most practical tools in the type system once you understand how to use it.

Let's walk through three patterns that'll make your TypeScript safer and your codebase cleaner.

Pattern 1: The Switch Statement That Never Lies

You've been here before. You have a union type representing different states:

type ApiState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: string }
  | { status: "error"; message: string };
Enter fullscreen mode Exit fullscreen mode

And you write a render function that handles each case:

function renderState(state: ApiState) {
  switch (state.status) {
    case "idle":
      return "Waiting...";
    case "loading":
      return "Loading...";
    case "success":
      return `Data: ${state.data}`;
    case "error":
      return `Error: ${state.message}`;
  }
}
Enter fullscreen mode Exit fullscreen mode

This works fine — until someone adds a new variant:

type ApiState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: string }
  | { status: "error"; message: string }
  | { status: "rate_limited"; retryAfter: number };  // New!
Enter fullscreen mode Exit fullscreen mode

Now renderState silently returns undefined for the new rate_limited case. No compile error. No warning. Just a runtime bug waiting to happen.

The fix: Add an exhaustiveness check using never:

function renderState(state: ApiState) {
  switch (state.status) {
    case "idle":
      return "Waiting...";
    case "loading":
      return "Loading...";
    case "success":
      return `Data: ${state.data}`;
    case "error":
      return `Error: ${state.message}`;
    default:
      // If a new variant is added, this line won't compile
      const _exhaustive: never = state;
      return _exhaustive;
  }
}
Enter fullscreen mode Exit fullscreen mode

Now when rate_limited is added, TypeScript throws:

Type '{ status: "rate_limited"; retryAfter: number; }' is not assignable to type 'never'.
Enter fullscreen mode Exit fullscreen mode

You add the missing case, the error goes away, and you ship confidently. This pattern alone is worth the price of admission — I've caught dozens of missed cases this way across production codebases.

This pattern works with discriminated unions, string literal unions, and any type where you handle all known variants. Use it everywhere you have a switch/match/then-else chain that must stay in sync with a union.

Pattern 2: Filtering Union Types with never and Conditional Types

never is the identity element of unions in TypeScript — T | never evaluates to T, and never gets automatically stripped from union types. This property is what makes TypeScript's built-in utility types work.

Here's how Exclude works under the hood:

type MyExclude<T, U> = T extends U ? never : T;
Enter fullscreen mode Exit fullscreen mode

When you write Exclude<string | number | boolean, boolean>, TypeScript distributes the conditional over each member:

  • string extends boolean ? never : stringstring
  • number extends boolean ? never : numbernumber
  • boolean extends boolean ? never : booleannever

The final result: string | number. The never gets stripped automatically.

You can use this pattern yourself. Say you have an event system and want to filter out certain event types:

type AppEvent =
  | { kind: "click"; x: number; y: number }
  | { kind: "keypress"; key: string }
  | { kind: "focus"; element: string }
  | { kind: "blur"; element: string }
  | { kind: "resize"; width: number; height: number };

// Filter only events with x/y coordinates
type PointerEvents = {
  [K in AppEvent["kind"]]: K extends "click" ? Extract<AppEvent, { kind: K }> : never;
}[AppEvent["kind"]];
// Result: { kind: "click"; x: number; y: number }
Enter fullscreen mode Exit fullscreen mode

Or more practically, create a helper that removes never-returning functions from a function union:

type Returnable<T> = T extends (...args: any[]) => never ? never : T;

type MyFunctions = (() => string) | (() => never) | ((x: number) => void);
// Result: (() => string) | ((x: number) => void)
Enter fullscreen mode Exit fullscreen mode

Pattern 3: Type-Level Validation with never in Mapped Types

You can embed never into mapped types to transform or validate object shapes at compile time.

Here's a pattern I use regularly — marking deprecated properties so accessing them is a compile error:

interface OldConfig {
  apiUrl: string;
  timeout: number;
  /** @deprecated Use apiUrl instead */
  endpoint: never;
}

// Or more dynamically, create a type that blocks certain keys:
type BlockedKeys = "password" | "secret" | "token";

type SafeConfig<T> = {
  [K in keyof T]: K extends BlockedKeys ? never : T[K];
};

interface RawConfig {
  apiUrl: string;
  password: string;
  timeout: number;
}

// Using SafeConfig makes password unusable
type Safe = SafeConfig<RawConfig>;
// { apiUrl: string; password: never; timeout: number }
Enter fullscreen mode Exit fullscreen mode

Another practical use — ensuring a function parameter is narrowed at compile time:

function assertUnreachable(x: never): never {
  throw new Error(`Unexpected value: ${x}`);
}
Enter fullscreen mode Exit fullscreen mode

This function is your safety net. Call it anywhere TypeScript should prove a code path is impossible. If you ever reach that path at runtime, it explodes loudly instead of silently corrupting state.

Understanding Never vs Void vs Unknown

A common point of confusion is the difference between these three:

Type What it means What you can assign to it
void Function returns no useful value (returns undefined) undefined and void
never Function never returns (throws or infinite loop) Nothing (bottom type)
unknown Could be anything (top type) Everything
any Opts out of type checking entirely Everything (dangerous)
function throwError(): never {
  throw new Error("Always throws");
}

function logMessage(): void {
  console.log("Returns undefined");
}

let n: never = undefined;  // ❌ Error: Type 'undefined' is not assignable to type 'never'
let v: void = undefined;   // ✅ OK
let u: unknown = 42;       // ✅ OK
Enter fullscreen mode Exit fullscreen mode

A function declared as returning never tells TypeScript (and other developers) that this function will never complete normally. This enables control-flow narrowing:

function fail(message: string): never {
  throw new Error(message);
}

function processValue(value: string | null): string {
  if (value === null) {
    return fail("Value was null");  // TypeScript knows this throws, not returns
  }
  return value.toUpperCase();  // TypeScript knows value is string here
}
Enter fullscreen mode Exit fullscreen mode

Putting It All Together

Here's a real-world example combining all three patterns — a Redux-style reducer with exhaustive type checking and runtime safety:

type Action =
  | { type: "increment"; amount: number }
  | { type: "decrement"; amount: number }
  | { type: "reset" }
  | { type: "set"; value: number };

type State = { count: number };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case "increment":
      return { count: state.count + action.amount };
    case "decrement":
      return { count: state.count - action.amount };
    case "reset":
      return { count: 0 };
    case "set":
      return { count: action.value };
    default:
      return assertUnreachable(action);
  }
}

function assertUnreachable(x: never): never {
  throw new Error(`Unexpected action: ${x}`);
}
Enter fullscreen mode Exit fullscreen mode

If you add a new action type to the union but forget to handle it in the reducer, TypeScript tells you immediately. The default branch catches it at compile time, and assertUnreachable catches it at runtime if someone bypasses the type system with a cast.

The Bottom Line

The never type isn't an academic curiosity. It's a sandbag in your type system's flood wall:

  • Exhaustive checks keep switch statements honest when your union grows
  • Conditional type filtering lets you build precise utility types that strip away unwanted variants
  • Mapped type validation blocks deprecated properties and enforces compile-time constraints

Start with the exhaustive check pattern — add a default case with a never assignment to every switch on a union type. It takes five seconds and pays for itself the first time someone adds a variant to a union you wrote three months ago.

Top comments (0)