On this page

Narrowing and type guards

14 min read TextCh. 4 — Advanced Types

What is narrowing?

TypeScript's control-flow analysis tracks the possible types of a value at each point in the code. When you check a condition that rules out some types, the compiler narrows the type of the variable inside that branch — giving you access to only the properties and methods that exist on the narrowed type.

Narrowing is not a runtime feature; it is purely a compile-time mechanism. At runtime your JavaScript runs exactly as written. Narrowing just helps the type checker understand what you already know from logic.

typeof narrowing

The typeof operator produces a string literal at runtime. TypeScript understands which types correspond to each literal and narrows accordingly:

function process(input: string | number | boolean): string {
  if (typeof input === "string") {
    // TypeScript knows: input is string
    return input.trim();
  } else if (typeof input === "number") {
    // TypeScript knows: input is number
    return input.toString(16); // hex
  } else {
    // TypeScript knows: input is boolean
    return input ? "yes" : "no";
  }
}

Truthiness narrowing

JavaScript's truthy/falsy rules also influence narrowing. Checking if (value) eliminates null, undefined, 0, "", NaN, and false:

function greet(name: string | null | undefined): string {
  if (name) {
    return `Hello, ${name}!`; // name is string here
  }
  return "Hello, stranger!";
}

For optional object properties the same pattern applies:

interface Config {
  timeout?: number;
}

function buildUrl(config: Config): string {
  const delay = config.timeout ?? 3000; // narrowed: number after nullish coalescing
  return `/?timeout=${delay}`;
}

Equality narrowing

When you compare two values with === or !==, TypeScript intersects their types to find what they have in common:

function align(x: "left" | "right" | "center", y: "left" | "right"): string {
  if (x === y) {
    // x and y must both be "left" | "right" here
    return `aligned: ${x}`;
  }
  return `x=${x}, y=${y}`;
}

The `in` operator

The in operator checks whether a property exists on an object. TypeScript uses this to narrow object types:

type Admin = { role: "admin"; permissions: string[] };
type Member = { role: "member"; plan: "free" | "pro" };

type AppUser = Admin | Member;

function describeUser(user: AppUser): string {
  if ("permissions" in user) {
    // TypeScript knows: user is Admin
    return `Admin with ${user.permissions.length} permissions`;
  }
  // TypeScript knows: user is Member
  return `Member on ${user.plan} plan`;
}

`instanceof` narrowing

instanceof checks the prototype chain at runtime. TypeScript narrows to the class type when the check succeeds:

class ValidationError extends Error {
  field: string;
  constructor(field: string, message: string) {
    super(message);
    this.field = field;
  }
}

class AuthError extends Error {
  code: number;
  constructor(code: number) {
    super("Unauthorized");
    this.code = code;
  }
}

function handleAppError(err: ValidationError | AuthError): void {
  if (err instanceof ValidationError) {
    console.error(`Invalid field "${err.field}": ${err.message}`);
  } else {
    console.error(`Auth failed with code ${err.code}`);
  }
}

Discriminated unions

A discriminated union (also called a tagged union) is a union type where every member carries a literal-typed field — the discriminant — that uniquely identifies it. This is the most robust narrowing pattern for complex data.

type RequestState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error";   error: string };

function render<T>(state: RequestState<T>): string {
  switch (state.status) {
    case "idle":    return "Waiting...";
    case "loading": return "Loading...";
    case "success": return `Data: ${JSON.stringify(state.data)}`;
    case "error":   return `Error: ${state.error}`;
  }
}

Custom type guards with `is`

When built-in checks are not enough, you can write a custom type guard — a function whose return type is a type predicate of the form parameter is Type:

interface Fish  { swim(): void }
interface Bird  { fly(): void  }

function isFish(animal: Fish | Bird): animal is Fish {
  return "swim" in animal;
}

function move(animal: Fish | Bird): void {
  if (isFish(animal)) {
    animal.swim(); // TypeScript knows it's Fish
  } else {
    animal.fly();  // TypeScript knows it's Bird
  }
}

TypeScript trusts the implementation of a type guard at your word. The responsibility for correctness lies with you — make sure the runtime check truly reflects the structural difference.

Assertion functions

An assertion function is similar to a type guard, but instead of returning boolean it throws when the condition fails. The caller does not need an if statement — TypeScript narrows the type for all subsequent code:

function assertIsString(value: unknown): asserts value is string {
  if (typeof value !== "string") {
    throw new TypeError(`Expected string, got ${typeof value}`);
  }
}

function processId(raw: unknown): string {
  assertIsString(raw);
  // TypeScript knows: raw is string from this point forward
  return raw.toUpperCase();
}

The never type and exhaustiveness

After all valid branches have been handled, TypeScript narrows the remaining type to never. Assigning it to a variable of type never is a compile-time exhaustiveness check — if you add a new union member without handling it, the assignment fails:

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

function describe(dir: Direction): string {
  switch (dir) {
    case "north": return "Going up";
    case "south": return "Going down";
    case "east":  return "Going right";
    case "west":  return "Going left";
    default:
      const _never: never = dir; // compile error if Direction gains a new member
      throw new Error(`Unhandled direction: ${_never}`);
  }
}

Practice

  1. typeof narrowing: Write a function stringify(value: string | number | boolean | null): string that uses typeof and equality checks to convert every possible type to a descriptive string.
  2. Discriminated union: Model a payment result as a discriminated union with three members: pending, approved (with transactionId: string), and declined (with reason: string). Write a processPayment function that handles every case.
  3. Custom type guard: Define interfaces SquareShape and CircleShape. Write an isSquare(shape) type guard using the in operator. Use it in a printArea function that computes the correct area formula for each shape.
  4. Exhaustiveness check: Add a fourth member refunded to your payment union and observe the compile error in processPayment. Fix it by handling the new case.

Next lesson: utility types — TypeScript's built-in helpers for transforming and composing types.

Use discriminated unions for complex state
When you have multiple related shapes of data (loading, success, error states; different event types; various shape geometries), model them as a discriminated union with a shared literal field like `kind` or `type`. The switch exhaustiveness pattern will catch missing cases at compile time.
typeof only works for primitive types
`typeof` can narrow to `string`, `number`, `boolean`, `bigint`, `symbol`, `undefined`, and `function`. It cannot distinguish between different object shapes — for that you need `instanceof`, the `in` operator, or a custom type guard.
Assertion functions vs type guards
A type guard function returns `boolean` and has a `pet is Cat`-style return type annotation. An assertion function returns `void` and throws when the condition fails — TypeScript understands that after the call, the asserted type holds in the remaining code.
typescript
// typeof narrowing
function formatInput(value: string | number): string {
  if (typeof value === "string") {
    return value.toUpperCase(); // string methods available here
  }
  return value.toFixed(2);     // number methods available here
}

console.log(formatInput("hello")); // "HELLO"
console.log(formatInput(3.14159)); // "3.14"

// instanceof narrowing
class NetworkError extends Error {
  statusCode: number;
  constructor(message: string, code: number) {
    super(message);
    this.statusCode = code;
  }
}

function handleError(err: Error): string {
  if (err instanceof NetworkError) {
    return `HTTP ${err.statusCode}: ${err.message}`; // statusCode visible
  }
  return err.message;
}
typescript
// Discriminated union — every member has a literal "kind" field
type Circle    = { kind: "circle";    radius: number };
type Rectangle = { kind: "rectangle"; width: number; height: number };
type Triangle  = { kind: "triangle";  base: number;  height: number };

type Shape = Circle | Rectangle | Triangle;

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:
      // Exhaustiveness check: this branch is unreachable if all cases are handled
      const _exhaustive: never = shape;
      return _exhaustive;
  }
}

// Custom type guard with "is"
interface Cat { meow(): void }
interface Dog { bark(): void }
type Pet = Cat | Dog;

function isCat(pet: Pet): pet is Cat {
  return "meow" in pet;
}

function makeNoise(pet: Pet): void {
  if (isCat(pet)) {
    pet.meow(); // Cat is guaranteed here
  } else {
    pet.bark(); // Dog is guaranteed here
  }
}