Polyglot
Languages TypeScript narrowing
TypeScript § narrowing

Narrowing

Narrowing is the process by which the TypeScript compiler refines a value’s type within a region of code. The compiler performs control-flow analysis — tracking how a variable’s type changes through if, switch, type guards, and assignments. The principal narrowing forms are typeof guards (typeof x === "string"), instanceof guards (x instanceof Animal), equality narrowing (x === null), truthiness narrowing (if (x)), the in operator, and user-defined type predicates (x is Foo). The combination — automatic narrowing in branches, exhaustiveness checking via never, discriminated unions — is one of TypeScript’s most distinctive features.

typeof narrowing

The typeof operator returns the JavaScript type as a string:

function format(x: string | number): string {
    if (typeof x === "string") {
        return x.toUpperCase();                  // x is narrowed to string
    }
    return x.toFixed(2);                          // x is narrowed to number
}

The typeof returns one of: "string", "number", "bigint", "boolean", "symbol", "undefined", "object", "function". A subtle pitfall: typeof null === "object":

function process(x: object | null) {
    if (typeof x === "object") {
        x.toString();                             // ERROR: x might still be null
    }
}

// Defence:
function process(x: object | null) {
    if (x !== null) {
        x.toString();                             // OK; narrowed to object
    }
}

Truthiness narrowing

if (x) narrows out null, undefined, 0, "", false, and NaN:

function format(s: string | undefined): string {
    if (s) {
        return s.toUpperCase();                   // s is narrowed to string
    }
    return "default";
}

function area(items: number[] | null): number {
    if (items) {
        return items.reduce((a, b) => a + b, 0); // narrowed to number[]
    }
    return 0;
}

A pitfall: empty arrays are truthy:

const arr: number[] = [];
if (arr) console.log("truthy");                  // prints

The convention checks arr.length for emptiness:

if (items && items.length > 0) {
    /* ... */
}

Equality narrowing

function process(x: string | number, y: string | boolean) {
    if (x === y) {
        // both narrowed to string (the only common type)
        x.toUpperCase();
        y.toUpperCase();
    }
}

function nullable(x: string | null) {
    if (x !== null) {
        return x.toUpperCase();                   // narrowed to string
    }
    return "default";
}

The === null, !== null, === undefined, !== undefined checks narrow the type accordingly.

For checking both null and undefined, the loose == is the conventional defence:

function nullable(x: string | null | undefined) {
    if (x == null) {                             // matches null OR undefined
        return "default";
    }
    return x.toUpperCase();                       // narrowed to string
}

(One of the rare conventional uses of ==.)

instanceof narrowing

class Animal { name: string = ""; }
class Dog extends Animal { breed: string = ""; }

function describe(x: Animal | Dog | string) {
    if (x instanceof Dog) {
        return `Dog: ${x.breed}`;                 // x is Dog
    }
    if (x instanceof Animal) {
        return `Animal: ${x.name}`;               // x is Animal
    }
    return `String: ${x}`;                        // x is string
}

instanceof checks the prototype chain; the mechanism admits substantial discrimination on class hierarchies.

in narrowing

The in operator checks whether a key exists in an object:

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

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

The form admits substantial discrimination on object structure.

Discriminated unions

A discriminated union (also called tagged union) is a union of object types with a common literal-typed property — the discriminant:

type Shape =
    | { kind: "circle"; radius: number }
    | { kind: "square"; side: number }
    | { kind: "triangle"; base: number; height: number };

function area(s: Shape): number {
    switch (s.kind) {
        case "circle":
            return Math.PI * s.radius ** 2;       // narrowed to circle variant
        case "square":
            return s.side ** 2;                  // narrowed to square
        case "triangle":
            return (s.base * s.height) / 2;      // narrowed to triangle
    }
}

The pattern is one of TypeScript’s most distinctive idioms; conventional for state machines, parser ASTs, action types in reducers, and similar.

Exhaustiveness checking with never

For exhaustive matching, assign the discriminant to a never-typed variable in the default branch:

function area(s: Shape): number {
    switch (s.kind) {
        case "circle":
            return Math.PI * s.radius ** 2;
        case "square":
            return s.side ** 2;
        case "triangle":
            return (s.base * s.height) / 2;
        default:
            const _exhaustive: never = s;        // ERROR if s is not narrowed to never
            return _exhaustive;
    }
}

If a new variant is added to Shape, the assignment fails: s cannot be narrowed to never, indicating an unhandled case. The mechanism admits compile-time exhaustiveness checking.

A reusable helper:

function assertNever(x: never): never {
    throw new Error(`Unexpected value: ${JSON.stringify(x)}`);
}

function area(s: Shape): number {
    switch (s.kind) {
        case "circle":   return Math.PI * s.radius ** 2;
        case "square":   return s.side ** 2;
        case "triangle": return (s.base * s.height) / 2;
        default:         return assertNever(s);
    }
}

User-defined type predicates

The value is Type return-type form admits user-defined narrowing:

function isString(x: unknown): x is string {
    return typeof x === "string";
}

function process(value: unknown) {
    if (isString(value)) {
        value.toUpperCase();                      // narrowed to string
    }
}

The compiler trusts the predicate’s return value: when isString(x) returns true, x is narrowed to string. The conventional uses are runtime validation:

interface User {
    name: string;
    email: string;
}

function isUser(x: unknown): x is User {
    return (
        typeof x === "object" &&
        x !== null &&
        "name" in x &&
        "email" in x &&
        typeof (x as User).name === "string" &&
        typeof (x as User).email === "string"
    );
}

const data: unknown = JSON.parse(input);
if (isUser(data)) {
    console.log(data.name);                       // OK; narrowed to User
}

The conventional discipline is to use type predicates sparingly and verify the assertions; an incorrect predicate produces unsoundness.

Assertion functions

Since TS 3.7, assertion functions admit narrowing via assertion rather than predicate:

function assert(condition: unknown, message?: string): asserts condition {
    if (!condition) throw new Error(message);
}

function assertIsString(x: unknown): asserts x is string {
    if (typeof x !== "string") throw new Error("not a string");
}

function process(value: unknown) {
    assertIsString(value);
    value.toUpperCase();                          // OK; narrowed to string
}

The asserts condition and asserts x is T return-type forms admit narrowing in the containing scope after the function returns. Conventional for assert-style validation.

Narrowing through assignment

Assignments narrow:

let x: string | number = getInput();

if (typeof x === "string") {
    x = x.toUpperCase();                          // x is string here AND after
} else {
    x = x.toFixed(2);                            // x is number here, becomes string
}
// x is string here (both branches narrowed it)

The compiler tracks the type of each binding through all assignments.

Narrowing through equality with literal

type Color = "red" | "green" | "blue" | "yellow";

function process(c: Color) {
    if (c === "red") {
        // c is "red" here
    } else {
        // c is "green" | "blue" | "yellow" here
    }
}

The compiler narrows on equality with literal values, including string literals, number literals, boolean literals, and null/undefined.

Narrowing arrays

function first(items: string[] | null): string | undefined {
    if (items && items.length > 0) {
        return items[0];                          // items is string[]
    }
    return undefined;
}

function processNumbers(items: (string | number)[]) {
    items.forEach(x => {
        if (typeof x === "number") {
            console.log(x.toFixed(2));            // x is number
        } else {
            console.log(x.toUpperCase());          // x is string
        }
    });
}

The compiler does not narrow array elements based on a check of one element; each iteration’s narrowing is independent.

The noUncheckedIndexedAccess option

Under noUncheckedIndexedAccess: true, indexed access returns T | undefined:

const arr: string[] = ["a", "b", "c"];

const x = arr[0];                                 // type: string | undefined

if (x) {
    console.log(x.toUpperCase());                  // narrowed to string
}

// For tuple types, the indexed access is precise:
const tuple: [string, number] = ["a", 1];
const a = tuple[0];                               // type: string (no undefined)

The conventional contemporary recommendation is to enable the option; it admits substantial safety against off-by-one and out-of-bounds errors.

Common patterns

Validate-and-use

function processAge(input: unknown): number | null {
    if (typeof input !== "number" || isNaN(input)) {
        return null;
    }
    if (input < 0 || input > 150) {
        return null;
    }
    return input;
}

Discriminated union for actions

type Action =
    | { type: "INCREMENT"; by: number }
    | { type: "DECREMENT"; by: number }
    | { type: "RESET" }
    | { type: "SET"; value: number };

function reducer(state: number, action: Action): number {
    switch (action.type) {
        case "INCREMENT": return state + action.by;
        case "DECREMENT": return state - action.by;
        case "RESET": return 0;
        case "SET": return action.value;
        default: return assertNever(action);
    }
}

Result type

type Result<T, E = Error> =
    | { ok: true; value: T }
    | { ok: false; error: E };

function divide(a: number, b: number): Result<number> {
    if (b === 0) {
        return { ok: false, error: new Error("division by zero") };
    }
    return { ok: true, value: a / b };
}

const r = divide(10, 2);
if (r.ok) {
    console.log(r.value);                         // narrowed to value branch
} else {
    console.log(r.error);                         // narrowed to error branch
}

The pattern admits explicit error handling without exceptions; treated in Error handling.

State machine

type State =
    | { status: "idle" }
    | { status: "loading" }
    | { status: "success"; data: string }
    | { status: "error"; error: string };

function render(state: State): string {
    switch (state.status) {
        case "idle":    return "Click to load";
        case "loading": return "Loading...";
        case "success": return state.data;
        case "error":   return `Error: ${state.error}`;
    }
}

JSON validation

function isPerson(x: unknown): x is Person {
    if (typeof x !== "object" || x === null) return false;
    const obj = x as Record<string, unknown>;
    return (
        typeof obj.name === "string" &&
        typeof obj.age === "number"
    );
}

const data: unknown = JSON.parse(input);
if (isPerson(data)) {
    console.log(data.name);
}

For substantial validation, libraries like zod, valibot, and io-ts admit declarative schema definition and runtime checking with type inference.

Optional chaining narrowing

function getCity(user: User | null): string | undefined {
    return user?.address?.city;
}

const city = getCity(currentUser);
if (city !== undefined) {
    console.log(city.toUpperCase());
}

instanceof for error types

try {
    riskyOperation();
} catch (e) {
    if (e instanceof NetworkError) {
        return retryAfter(e.retryAfter);
    }
    if (e instanceof ValidationError) {
        return reportField(e.field);
    }
    throw e;
}

Generic narrowing

function findFirst<T>(arr: T[], pred: (x: T) => boolean): T | undefined {
    for (const x of arr) {
        if (pred(x)) return x;
    }
    return undefined;
}

const num = findFirst([1, 2, 3], x => x > 2);
if (num !== undefined) {
    console.log(num.toFixed(2));                  // narrowed to number
}

as const for narrow inference

const directions = ["up", "down", "left", "right"] as const;
type Direction = typeof directions[number];      // "up" | "down" | "left" | "right"

function move(d: Direction) { /* ... */ }
move("up");                                       // OK

The pattern admits enum-like behaviour without enum.

A note on the limits of narrowing

Narrowing is flow-sensitive but does not survive arbitrary control flow:

function process(x: string | undefined) {
    if (x === undefined) return;

    helper();                                     // does helper() reset x's narrowing?

    x.toUpperCase();                              // OK if helper() couldn't change x
}

function helper() {
    // could mutate global state
}

For variables captured in closures or accessed from other code, the compiler may re-widen the type after function calls:

let x: string | undefined = getInput();

if (x !== undefined) {
    fn();                                         // calling fn might widen x's type
    x.toUpperCase();                              // ERROR (x re-widened to string | undefined)
}

// Defence: narrow into a const:
if (x !== undefined) {
    const safe = x;
    fn();
    safe.toUpperCase();                           // OK
}

The pattern is conventional for substantial narrowing across function calls.

A note on the conventional discipline

The contemporary TypeScript narrowing advice:

  • Use discriminated unions for closed sets of variants.
  • Use never and assertNever for exhaustiveness checking.
  • Use typeof and instanceof for primitive and class narrowing.
  • Use in for object-property narrowing.
  • Use type predicates (x is T) for runtime validation.
  • Use assertion functions (asserts x is T) for assert-style narrowing.
  • Enable noUncheckedIndexedAccess — array access returns T | undefined.
  • Use unknown over any — narrows to specific types.
  • Use ==null (rare exception) for “null or undefined”.
  • Use libraries (zod, etc.) for substantial validation.

The combination — flow-sensitive type analysis, discriminated unions, exhaustiveness checking, type predicates, the in/instanceof/typeof operators — is the substance of TypeScript’s narrowing surface. The discipline produces substantial type safety with relatively little annotation overhead.