TypeScript’s static analysis understands when a value’s type becomes more specific. Narrowing is the process of refining a broad type to a narrower one based on runtime checks.

The Problem

  function printId(id: string | number) {
    // console.log(id.toUpperCase()); // Error: toUpperCase doesn't exist on number
    console.log(id);
}
  

We need to tell TypeScript which branch we are in.

typeof Guards

  function printId(id: string | number): void {
    if (typeof id === 'string') {
        console.log(id.toUpperCase()); // id is string here
    } else {
        console.log(id.toFixed(2));    // id is number here
    }
}

printId('abc-123');
printId(42);
  

typeof works for primitives: string, number, boolean, bigint, symbol, undefined, function.

instanceof Guards

Check against class constructors:

  class Dog {
    bark() { return 'Woof!'; }
}

class Cat {
    meow() { return 'Meow!'; }
}

function speak(animal: Dog | Cat): string {
    if (animal instanceof Dog) {
        return animal.bark();
    }
    return animal.meow();
}
  

in Operator

Check for property existence:

  interface Bird {
    fly(): void;
    layEggs(): void;
}

interface Fish {
    swim(): void;
    layEggs(): void;
}

function move(animal: Bird | Fish): void {
    if ('fly' in animal) {
        animal.fly();
    } else {
        animal.swim();
    }
}
  

Discriminated Unions

Add a shared literal property (discriminant) to each variant:

  interface Circle {
    kind: 'circle';
    radius: number;
}

interface Rectangle {
    kind: 'rectangle';
    width: number;
    height: number;
}

type Shape = Circle | Rectangle;

function area(shape: Shape): number {
    switch (shape.kind) {
        case 'circle':
            return Math.PI * shape.radius ** 2;
        case 'rectangle':
            return shape.width * shape.height;
    }
}
  

TypeScript knows the exact shape in each case branch.

Custom Type Guards

Return a type predicate with is:

  interface Admin {
    role: 'admin';
    permissions: string[];
}

interface RegularUser {
    role: 'user';
    name: string;
}

type Account = Admin | RegularUser;

function isAdmin(account: Account): account is Admin {
    return account.role === 'admin';
}

function handleAccount(account: Account) {
    if (isAdmin(account)) {
        console.log(account.permissions);
    } else {
        console.log(account.name);
    }
}
  

Narrowing is essential for working with unions and external data. Next, we explore advanced type composition techniques.