← thecodex.expert · The Codex Family of Knowledge
TypeScript

Type Narrowing

TypeScript watches your if statements — a plain typeof check inside one is enough for it to know exactly which member of a union you’re holding, with zero extra syntax.

TypeScript 6.0 typeof, instanceof, in Last verified:
Where this bites you

Narrowing is TypeScript's best guess about a value at that point in the code — and it assumes a function call in between doesn't change the property it narrowed. When the function does mutate it, the narrowed type is simply wrong: TypeScript keeps saying "pending" while the real value is "cancelled", and code relying on that compiles cleanly.

TypeScriptstale_narrowing_bug.ts
type Job = { status: "pending" | "cancelled" };

const job: Job = { status: "pending" };

function cancel(): void {
    job.status = "cancelled";
}

if (job.status === "pending") {
    cancel();
    const shown: "pending" = job.status;   // TypeScript still believes "pending"
    console.log(shown);
}
// tsc --strict: no errors
// node: cancelled
Canonical Definition

TypeScript's control-flow analysis watches conditional checks and progressively narrows a variable's type within each branch — a typeof check narrows a union to one specific primitive type, instanceof narrows to a specific class, and the in operator narrows based on whether a named property exists on the value. This happens automatically from ordinary JavaScript runtime checks; no special TypeScript-only syntax is required.

typeof narrowing

Inside each branch of the check, TypeScript treats value as exactly the narrowed type — no cast or assertion needed.

TypeScripttypeof_narrowing.ts
function format(value: string | number) {
    if (typeof value === "string") {
        return value.toUpperCase();   // TypeScript knows: value is string here
    }
    return value.toFixed(2);           // TypeScript knows: value is number here
}

instanceof and in narrowing

instanceof narrows to a specific class; in narrows based on whether a property exists, useful for distinguishing object shapes without a class hierarchy at all.

TypeScriptinstanceof_in.ts
class Dog { bark() { return "Woof"; } }
class Cat { meow() { return "Meow"; } }

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

type Fish = { swim: () => void };
type Bird = { fly: () => void };

function move(pet: Fish | Bird) {
    if ("swim" in pet) {
        pet.swim();    // narrowed to Fish
    } else {
        pet.fly();      // narrowed to Bird
    }
}

Discriminated unions: the cleanest pattern

A shared literal-type property (here, kind) lets TypeScript narrow an entire union precisely from one check, and a switch over that property gets exhaustiveness checking almost for free — this is widely considered the most robust narrowing pattern for anything beyond a couple of simple cases.

TypeScriptdiscriminated_union.ts
type Circle = { kind: "circle"; radius: number };
type Square = { kind: "square"; side: number };
type Shape = Circle | Square;

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

This is a deliberate trade-off in TypeScript's design: re-checking every narrowed property after every function call would make narrowing almost useless, so the checker assumes calls don't mutate what it has already narrowed. The defence is to make state changes visible to it. Returning a new value and reassigning resets the narrowing, so TypeScript sees the full union again (an annotation of "pending" there is now a compile error, as it should be):

TypeScriptstale_narrowing_fix.ts
type Job = { status: "pending" | "cancelled" };

let job: Job = { status: "pending" };

function cancel(j: Job): Job {
    return { ...j, status: "cancelled" };
}

if (job.status === "pending") {
    job = cancel(job);                      // reassignment resets the narrowing
    const shown: Job["status"] = job.status;
    console.log(shown);   // cancelled  -- and the type now admits it
}

Sources

1
Microsoft. TypeScript Handbook, "Narrowing", typescriptlang.org/docs/handbook/2/narrowing.html.