TypeScript Never Type: Exhaustive Checking

The `never` type in TypeScript represents the type of values that never occur. Unlike `void` (which represents the absence of a value) or `undefined` (which represents an undefined value), `never`...

Key Insights

  • The never type represents values that should never exist, making it perfect for catching unhandled cases at compile-time rather than discovering them in production
  • Exhaustive checking with never forces TypeScript to verify you’ve handled every possible case in discriminated unions, eliminating an entire class of bugs
  • An assertNever helper function is the standard pattern for implementing exhaustive checks in switch statements and conditional logic

Understanding the Never Type

The never type in TypeScript represents the type of values that never occur. Unlike void (which represents the absence of a value) or undefined (which represents an undefined value), never indicates that a value cannot exist at all.

TypeScript automatically infers never in specific scenarios:

// Function that always throws
function throwError(message: string): never {
  throw new Error(message);
}

// Function with infinite loop
function infiniteLoop(): never {
  while (true) {
    // This never returns
  }
}

// Unreachable code
function processValue(value: string | number) {
  if (typeof value === 'string') {
    return value.toUpperCase();
  } else if (typeof value === 'number') {
    return value.toFixed(2);
  }
  // TypeScript knows this is unreachable
  // value has type 'never' here
}

While these examples show where never appears naturally, its real power lies in exhaustive type checking—ensuring you’ve handled every possible case in your code.

The Problem with Non-Exhaustive Checking

Consider a common scenario: handling different shapes in a graphics application. You define a discriminated union and write a function to calculate area:

type Circle = {
  kind: 'circle';
  radius: number;
};

type Square = {
  kind: 'square';
  sideLength: number;
};

type Triangle = {
  kind: 'triangle';
  base: number;
  height: number;
};

type Shape = Circle | Square | Triangle;

function getArea(shape: Shape): number {
  switch (shape.kind) {
    case 'circle':
      return Math.PI * shape.radius ** 2;
    case 'square':
      return shape.sideLength ** 2;
    // Oops! Forgot triangle
  }
  // TypeScript doesn't complain, but this returns undefined
}

const triangle: Triangle = { kind: 'triangle', base: 10, height: 5 };
const area = getArea(triangle); // Returns undefined at runtime!

This code compiles without errors, but it has a critical bug. When you pass a triangle, the function returns undefined instead of a number. This violates the function’s type signature and will cause runtime errors when other code assumes it receives a valid number.

The problem gets worse as your codebase grows. Six months later, a teammate adds a Rectangle type to the Shape union. Every function handling shapes should be updated, but TypeScript won’t warn you about the missing cases. These bugs slip through to production.

Implementing Exhaustive Checking

The solution is to use the never type to force compile-time errors when cases are missing. Here’s the pattern:

function assertNever(value: never): never {
  throw new Error(`Unhandled value: ${JSON.stringify(value)}`);
}

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:
      return assertNever(shape);
  }
}

Now if you comment out the triangle case:

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:
      return assertNever(shape); 
      // Error: Argument of type 'Triangle' is not assignable to parameter of type 'never'
  }
}

TypeScript immediately flags the error. Here’s why this works: In the default case, TypeScript narrows the type of shape based on the handled cases. If all cases are covered, shape has type never in the default branch (because no value could reach there). The assertNever function only accepts never, so it compiles. But if a case is missing, shape still has that unhandled type (like Triangle), which cannot be assigned to never, causing a compile error.

Real-World Use Cases

Exhaustive checking shines in state management. Here’s a Redux-style reducer with action types:

type State = {
  count: number;
  status: 'idle' | 'loading' | 'error';
};

type IncrementAction = { type: 'INCREMENT' };
type DecrementAction = { type: 'DECREMENT' };
type ResetAction = { type: 'RESET' };
type SetLoadingAction = { type: 'SET_LOADING'; loading: boolean };

type Action = IncrementAction | DecrementAction | ResetAction | SetLoadingAction;

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'INCREMENT':
      return { ...state, count: state.count + 1 };
    case 'DECREMENT':
      return { ...state, count: state.count - 1 };
    case 'RESET':
      return { ...state, count: 0 };
    case 'SET_LOADING':
      return { ...state, status: action.loading ? 'loading' : 'idle' };
    default:
      return assertNever(action);
  }
}

When someone adds a new action type to the Action union, TypeScript immediately highlights every reducer that doesn’t handle it. This prevents the common bug where new actions silently do nothing because they fall through to a default case.

API response handlers are another excellent use case:

type ApiResponse = 
  | { status: 'success'; data: User[] }
  | { status: 'error'; error: string }
  | { status: 'loading' }
  | { status: 'unauthorized'; redirectUrl: string };

function handleResponse(response: ApiResponse): void {
  switch (response.status) {
    case 'success':
      displayUsers(response.data);
      break;
    case 'error':
      showError(response.error);
      break;
    case 'loading':
      showSpinner();
      break;
    case 'unauthorized':
      window.location.href = response.redirectUrl;
      break;
    default:
      assertNever(response);
  }
}

Advanced Patterns

The never type enables sophisticated type-level programming. You can filter union types by excluding certain members:

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

type User = {
  id: number;
  name: string;
  email: string;
  save: () => void;
  delete: () => void;
};

type UserDataKeys = NonFunctionKeys<User>; // 'id' | 'name' | 'email'

This mapped type iterates over all keys in T. For function properties, it maps the key to never. For non-function properties, it maps the key to itself. The [keyof T] index access then collects all the resulting keys, and TypeScript automatically removes never from the union.

You can build utility types that extract only serializable properties:

type Serializable = string | number | boolean | null | undefined;

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

type OnlySerializable<T> = Pick<T, SerializableKeys<T>>;

type ComplexObject = {
  id: number;
  name: string;
  callback: () => void;
  metadata: { created: Date };
};

type SimpleObject = OnlySerializable<ComplexObject>; 
// { id: number; name: string }

Common Pitfalls and Best Practices

Not every situation requires exhaustive checking. Sometimes you genuinely want a default case with fallback behavior:

type LogLevel = 'debug' | 'info' | 'warn' | 'error';

// With exhaustive checking - fails if new level added
function getColor(level: LogLevel): string {
  switch (level) {
    case 'debug': return 'gray';
    case 'info': return 'blue';
    case 'warn': return 'orange';
    case 'error': return 'red';
    default: return assertNever(level);
  }
}

// With default fallback - handles unknown levels gracefully
function getIcon(level: LogLevel): string {
  switch (level) {
    case 'error': return '❌';
    case 'warn': return '⚠️';
    default: return 'ℹ️'; // Reasonable default for info, debug, and future levels
  }
}

Use exhaustive checking when:

  • Every case requires distinct handling
  • Missing a case is a bug, not an edge case
  • You want compile-time verification of completeness

Use default fallbacks when:

  • Multiple cases share the same behavior
  • New cases should gracefully degrade
  • You’re handling external data that might include unexpected values

One mistake developers make is adding a default case “just in case” even when exhaustive checking would be better:

// Bad: Defeats exhaustive checking
function getArea(shape: Shape): number {
  switch (shape.kind) {
    case 'circle':
      return Math.PI * shape.radius ** 2;
    case 'square':
      return shape.sideLength ** 2;
    default:
      return 0; // Silently returns wrong value for triangle
  }
}

Trust TypeScript’s type system. If you’ve covered all cases, you don’t need a default. If you haven’t, you want the compiler to tell you.

The never type and exhaustive checking transform TypeScript from a type annotation system into a verification system. Instead of documenting what your code should do, you’re proving it handles every case. This shifts entire categories of bugs from runtime to compile-time, where they’re caught before any user sees them. Master this pattern, and you’ll write more robust code with confidence that your type coverage is complete.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.