All posts

TypeScript Narrowing: Beyond typeof and instanceof

typescriptpatterns

A practical look at discriminated unions, exhaustiveness checks, and assertion functions — the narrowing patterns that actually improve real codebases.


TypeScript's type narrowing is one of its most powerful features, and also one of the most underused. Most developers know typeof and instanceof. Fewer know about discriminated unions and assertion functions, which are the patterns I reach for most often on production codebases.

The problem typeof can't solve

type Shape =
  { kind: "circle"; radius: number } | { kind: "square"; side: number };
 
function area(shape: Shape): number {
  if (typeof shape === "object") {
    // Still doesn't narrow — TypeScript knows it's an object, nothing more
    return 0;
  }
  return 0;
}

typeof narrows to primitive types. For objects, it's nearly useless.

Discriminated unions

When you control the type definition, add a literal kind field. TypeScript narrows on it automatically:

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2; // shape is { kind: "circle"; radius: number }
    case "square":
      return shape.side ** 2; // shape is { kind: "square"; side: number }
  }
}

This is readable, exhaustive, and the compiler enforces it. If you add a triangle to Shape without updating the switch, TypeScript surfaces the gap.

Exhaustiveness checks

That gap-surfacing requires one extra step: a never assertion in the default branch.

function assertNever(value: never, message?: string): never {
  throw new Error(message ?? `Unexpected value: ${JSON.stringify(value)}`);
}
 
function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.side ** 2;
    default:
      return assertNever(shape); // TypeScript error if any case is unhandled
  }
}

assertNever takes never. If every kind is covered, shape in the default branch is never, and the code compiles. If you add a new variant and forget to handle it, shape is no longer never — you get a compile error pointing exactly at the gap. Zero runtime cost for a covered switch.

Assertion functions

For validation at system boundaries (API responses, form inputs), use assertion functions:

function assertIsString(val: unknown, field: string): asserts val is string {
  if (typeof val !== "string") {
    throw new TypeError(`${field} must be a string, got ${typeof val}`);
  }
}
 
function processInput(raw: unknown) {
  assertIsString(raw, "input");
  // raw is narrowed to string here
  return raw.toUpperCase();
}

The asserts val is T return type tells TypeScript: "if this function returns normally (doesn't throw), val is now T." You get runtime validation and type narrowing in one call.

Summary

PatternUse when
typeofNarrowing primitives (string, number, boolean)
instanceofNarrowing class instances
Discriminated unionYou own the type definition — almost always prefer this
assertNeverExhaustiveness on switch/if chains
Assertion functionValidating unknown data at boundaries

These patterns are composable. A real codebase typically uses all five.