TypeScriptExhaustiveness Checking (never)

Exhaustiveness Checking (never)

Exhaustiveness checking is a compile-time technique that forces you to handle every case in a union type. When you add a new variant to a union, TypeScript immediately flags every switch statement or conditional that does not handle it — before the code ships. The never type is the mechanism that makes this work.

The never Type

never is TypeScript's bottom type — a type that has no values. It represents:

  • Values that can never occur
  • Code paths that are unreachable
  • Functions that never return (they throw or loop forever)

When TypeScript narrows a union to the point that no cases remain, the remaining type is never.

TS
// A function that never returns
function fail(message: string): never {
  throw new Error(message);
}

// An infinite loop also returns never
function loop(): never {
  while (true) {}
}

// never in a union disappears
type T = string | never; // string

// A value of type never can be assigned to any type
// (but nothing can be assigned to never)
function assertUnreachable(x: never): never {
  throw new Error("Unhandled case reached");
}
Note
never is the identity element of unions: string | never simplifies to string. This is why it disappears when a switch case is fully handled.
Exhaustiveness with switch Statements

The classic exhaustiveness check uses a default branch that assigns the discriminant to a never variable. If you have handled all cases, TypeScript infers the type as never in the default — and the assignment succeeds. If you miss a case, TypeScript infers a real type there, causing a compile error.

TS
type Shape =
  | { kind: "circle";    radius: number }
  | { kind: "rectangle"; width: number; height: number }
  | { kind: "triangle";  base: number;  height: number };

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height;
    case "triangle":
      return (shape.base * shape.height) / 2;
    default: {
      // If all cases are handled, shape is 'never' here
      const _exhaustive: never = shape; // compiles fine
      return _exhaustive; // never is assignable to any type
    }
  }
}
Success
Now add a fourth variant: | { kind: "ellipse"; rx: number; ry: number }. TypeScript immediately reports an error in the default branch: Type '{ kind: "ellipse"; ... }' is not assignable to type 'never'. You must handle it before the code compiles.
Extracting a Reusable Helper

Repeating the assignment-to-never pattern in every switch gets verbose. Extract it into a shared helper function:

TS
// Reusable exhaustiveness guard
function assertNever(x: never, message?: string): never {
  throw new Error(message ?? `Unhandled case: ${JSON.stringify(x)}`);
}

// Now every switch is concise
function describe(shape: Shape): string {
  switch (shape.kind) {
    case "circle":    return `Circle r=${shape.radius}`;
    case "rectangle": return `Rect ${shape.width}x${shape.height}`;
    case "triangle":  return `Tri base=${shape.base}`;
    default:          return assertNever(shape);
  }
}
Tip
The assertNever helper also acts as a runtime guard: if somehow an unexpected variant reaches that branch at runtime (e.g., from an API), it throws a meaningful error instead of silently returning undefined.
Exhaustiveness in if-else Chains

Switch statements are not the only place for exhaustiveness checking. You can use the same pattern with if-else chains.

TS
type Status = "pending" | "active" | "suspended" | "closed";

function getStatusColor(status: Status): string {
  if (status === "pending")   return "yellow";
  if (status === "active")    return "green";
  if (status === "suspended") return "orange";
  if (status === "closed")    return "gray";

  // After all branches, status is never
  return assertNever(status);
}
Discriminated Unions and Exhaustiveness

Exhaustiveness checking pairs naturally with discriminated unions — the discriminant property is what TypeScript narrows on in each branch.

TS
// Redux-style action reducer with exhaustive handling
type CounterAction =
  | { type: "INCREMENT"; by: number }
  | { type: "DECREMENT"; by: number }
  | { type: "SET";       value: number }
  | { type: "RESET" };

function counterReducer(state: number, action: CounterAction): number {
  switch (action.type) {
    case "INCREMENT": return state + action.by;
    case "DECREMENT": return state - action.by;
    case "SET":       return action.value;
    case "RESET":     return 0;
    default:          return assertNever(action);
  }
}
// Add | { type: "MULTIPLY"; factor: number } to CounterAction
// → TypeScript immediately flags the default branch
Exhaustiveness with Object Maps

An alternative to switch statements is an exhaustive object map. TypeScript validates that every key in the union is present in the map.

TS
type Direction = "north" | "south" | "east" | "west";

// Record<Direction, ...> requires all four keys
const VECTORS: Record<Direction, [number, number]> = {
  north: [0,  1],
  south: [0, -1],
  east:  [1,  0],
  west:  [-1, 0],
  // Removing any key causes a compile error
};

function move(dir: Direction): [number, number] {
  return VECTORS[dir]; // always safe — map is exhaustive
}

// Same pattern for labels, colors, icons, etc.
type Priority = "low" | "medium" | "high" | "critical";

const PRIORITY_COLORS: Record<Priority, string> = {
  low:      "#gray",
  medium:   "#blue",
  high:     "#orange",
  critical: "#red",
};
Note
The Record<K, V> approach is often cleaner than a switch when the output is a simple value. Add a new member to the union and TypeScript tells you exactly which maps need updating.
Exhaustiveness in Type-Level Code

never also appears in conditional types to filter out union members or signal impossible branches.

TS
// Extract only string members from a union
type StringsOnly<T> = T extends string ? T : never;

type Colors = StringsOnly<"red" | 42 | "blue" | true>;
// Colors = "red" | "blue"  (number and boolean become never and disappear)

// Exclude specific members
type Exclude<T, U> = T extends U ? never : T;

type WithoutNull<T> = Exclude<T, null | undefined>;
type Clean = WithoutNull<string | number | null | undefined>;
// Clean = string | number

// Filter a tuple to only numeric members
type NumericFields<T extends readonly (keyof any)[]> = {
  [K in keyof T]: T[K] extends number ? T[K] : never;
};
Exhaustiveness Checking for String Enums

TS
// TypeScript enums work the same way
enum Weekday {
  Monday = "monday",
  Tuesday = "tuesday",
  Wednesday = "wednesday",
  Thursday = "thursday",
  Friday = "friday",
}

function isWorkFromHome(day: Weekday): boolean {
  switch (day) {
    case Weekday.Monday:    return false;
    case Weekday.Tuesday:   return true;
    case Weekday.Wednesday: return false;
    case Weekday.Thursday:  return true;
    case Weekday.Friday:    return true;
    default:                return assertNever(day);
  }
}

// Alternatively, with a string union (preferred over enums in modern TS)
type WeekdayStr = "monday" | "tuesday" | "wednesday" | "thursday" | "friday";

const WFH_DAYS: Record<WeekdayStr, boolean> = {
  monday:    false,
  tuesday:   true,
  wednesday: false,
  thursday:  true,
  friday:    true,
};
Partial Exhaustiveness (Catch-All with never)

Sometimes you want to handle some cases explicitly and keep a catch-all for the rest — but still be notified when new members are added that you might want to handle specially.

TS
type NotificationKind = "email" | "sms" | "push" | "in-app" | "webhook";

function getIcon(kind: NotificationKind): string {
  switch (kind) {
    case "email":  return "✉";
    case "sms":    return "💬";
    case "push":   return "🔔";
    case "in-app": return "📢";
    case "webhook":
      // intentionally handled the same as a default
      return "🔗";
    default:
      // If you added "webhook" above, the default is still never — compile error disappears
      // If you forget to add a new kind, default catches it at runtime
      return assertNever(kind);
  }
}
Common Mistakes

Mistake

Result

Fix

No default branch in switch

Missing cases silently return undefined

Add default: assertNever(x)

Default returns a value without assertNever

No compile-time exhaustiveness

Call assertNever in the default

Using any for the discriminant type

TypeScript skips narrowing

Use a proper union type

Forgetting to update switch after union change

Runtime bug with no warning

assertNever makes it a compile error

Object map without Record<Union, V>

Missing keys are not caught

Use Record<Union, V> explicitly

never in Function Signatures

Functions annotated with a return type of never tell TypeScript that after the call, the current code path is dead. This enables narrowing in the calling code.

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

function getUser(id: number): User | undefined {
  const user = db.find(id);
  if (!user) fail(`User ${id} not found`); // throws, returns never
  return user; // TypeScript knows user is User (not undefined) here
}

// narrowing after a never-returning function
function processConfig(config: Config | null) {
  if (!config) fail("config must be provided");
  // config: Config here — null narrowed away by the never call
  console.log(config.host);
}

// Infinite loop also returns never
function startServer(): never {
  while (true) {
    handleNextRequest();
  }
}
Note
When a function returns never, TypeScript removes the possibility of the value being falsy after the call. This is why fail() effectively narrows the type of the variables in scope.
Combining never with Conditional Types

TS
// Filter union members with never
type OnlyStrings<T> = T extends string ? T : never;
type Strings = OnlyStrings<"a" | 1 | "b" | true>; // "a" | "b"

// NonNullable is built into TypeScript using this pattern
type NonNullable<T> = T extends null | undefined ? never : T;
type Clean = NonNullable<string | null | undefined>; // string

// Extract — keep only members assignable to U
type Extract<T, U> = T extends U ? T : never;
type Extracted = Extract<"a" | "b" | 1 | 2, string>; // "a" | "b"

// Exclude — remove members assignable to U
type Exclude<T, U> = T extends U ? never : T;
type NoNumbers = Exclude<"a" | "b" | 1 | 2, number>; // "a" | "b"
Exhaustiveness and Code Generation

One practical benefit of exhaustive unions is that generated code (from a schema, GraphQL introspection, or a protobuf file) can add new variants safely. Your TypeScript build will fail at every unhandled switch, giving you an explicit checklist of what to update.

TS
// Generated from GraphQL schema — new types are added automatically
// by the code generator. Your switch statements break until you handle them.
type PaymentMethod =
  | { __typename: "CreditCard"; last4: string; brand: string }
  | { __typename: "BankTransfer"; iban: string }
  | { __typename: "Crypto"; address: string; coin: string };

// Next sprint the generator adds:
// | { __typename: "DigitalWallet"; provider: "apple" | "google" }

function getPaymentLabel(method: PaymentMethod): string {
  switch (method.__typename) {
    case "CreditCard":    return `${method.brand} ending ${method.last4}`;
    case "BankTransfer":  return `Bank transfer (${method.iban.slice(-4)})`;
    case "Crypto":        return `${method.coin} at ${method.address.slice(0, 6)}...`;
    default:              return assertNever(method);
    // When DigitalWallet is added: build breaks here → go implement it
  }
}
Success
Schema-driven development combined with exhaustiveness checking means that every time your API or data model grows, the TypeScript compiler creates an automatic TODO list for you.
Exhaustiveness with Intersection Types

When a union member has additional required properties, TypeScript's narrowing tracks all of them, and the exhaustive check validates the full shape.

TS
type BaseEvent = { timestamp: number; source: string };

type UserEvent =
  | (BaseEvent & { type: "login";   userId: string })
  | (BaseEvent & { type: "logout";  userId: string })
  | (BaseEvent & { type: "purchase"; userId: string; amount: number });

function handleEvent(event: UserEvent): void {
  switch (event.type) {
    case "login":
      console.log(`${event.userId} logged in at ${event.timestamp}`);
      break;
    case "logout":
      console.log(`${event.userId} logged out`);
      break;
    case "purchase":
      // event.amount is available here — TypeScript narrowed to purchase branch
      console.log(`${event.userId} spent ${event.amount}`);
      break;
    default:
      assertNever(event);
  }
}
Summary
  • never is the bottom type — it has no values and is assignable to every type

  • When TypeScript narrows all union cases, the remaining type is never

  • Assign to a never variable in the default/else branch to enforce exhaustiveness

  • Extract assertNever as a reusable helper for cleaner, throw-on-miss behavior

  • Record<UnionKey, V> enforces exhaustive object maps at the type level

  • never in conditional types filters union members that fail the condition

  • Functions returning never tell TypeScript the current code path is unreachable after the call

  • Code generation workflows benefit enormously — new variants break the build at every unhandled switch

  • Exhaustiveness checking is your best defense against adding a union variant and forgetting to handle it