Type Narrowing in TypeScript: typeof, instanceof, in, Discriminated Unions and Type Predicates

Key takeaways

A focused guide to TypeScript narrowing: how control flow analysis tracks types branch by branch, which runtime checks it understands, how discriminated unions and assertNever turn missing cases into compile errors, and the situations where narrowing is lost.

Introduction

When you declare a variable as string | number, TypeScript does not force you to treat it as string | number everywhere. The compiler performs control flow analysis (CFA): it walks through your if statements, switch cases, early returns, and logical operators, and for each point in the code it asks “given the checks that must have passed to get here, what is the most specific type this variable could still be?” Inside an if (typeof value === "string") block, the answer is string, even though the variable’s declared type never changes. The declared type is the ceiling; the narrowed type is what the compiler actually lets you do at a given line.

This guide is only about that second part. What union types, intersection types, literal types, and type aliases are—and the API-response and state-machine examples built from them—is covered in Advanced TypeScript Types: Union & Intersection. Here we look at how narrowing works, which runtime checks the compiler recognizes, how discriminated unions scale, and the places where narrowing silently stops working. That last part is what separates “I added a typeof check and the error went away” from actually reasoning about your program’s types.


How control flow analysis narrows

Narrowing is driven by three kinds of events the compiler tracks:

  • Checks it recognizes (typeof, instanceof, in, equality, truthiness, type predicates) narrow a variable in the branch where the check is true, and narrow it the other way in the branch where it is false.
  • Assignments reset the narrowed type to whatever was assigned. let x: string | number = 1; is narrowed to number right after the assignment, even though its declared type is the union.
  • Control flow joins merge types. After an if/else where one branch narrowed to string and the other to number, the variable is string | number again below the join.
function describe(input: string | number | null) {
    if (input === null) {
        return "nothing";          // input: null
    }
    // input: string | number — the null case returned early
    if (typeof input === "number") {
        input = input.toFixed(2);  // assignment: input is now string
    }
    return input.toUpperCase();    // input: string on both paths
}

Early returns are the most readable way to use this: handle the special cases at the top and return, and the rest of the function works with a narrowed type without extra nesting. The else branch never needs its own explicit check when the union has two members—if it is not a string, the only remaining possibility is number. That “narrowing by elimination” is convenient, but it also means that adding a third member to the union later silently changes what the else branch sees. Section 4 shows how to make that change a compile error.

TypeScript 4.4 added aliased conditions: a check stored in a const still narrows when you test the const later.

function format(value: string | number) {
    const isString = typeof value === "string";
    if (isString) {
        return value.toUpperCase();   // ✅ narrowed through the alias
    }
    return value.toFixed(2);
}

This only works when both the condition and the narrowed variable are const or readonly (or parameters that are never reassigned), because otherwise the compiler cannot be sure the alias still describes the variable.


The built-in guards

typeof

function processInput(input: string | number) {
    if (typeof input === "string") {
        return input.toUpperCase();
    }
    return input.toFixed(2);
}

typeof narrowing is the cheapest and most reliable guard because typeof is a genuine JavaScript operator with well-defined output. It only understands JavaScript’s primitive tags ("string", "number", "bigint", "boolean", "symbol", "undefined", "object", "function"), so it cannot tell two object shapes apart. Its sharp edge is the decades-old quirk typeof null === "object": for a Config | null value, typeof value === "object" does not exclude null, and TypeScript narrows accordingly to Config | null. You still need a separate value !== null check before reading properties.

instanceof

class HttpError extends Error { constructor(public status: number) { super(`HTTP ${status}`); } }

function report(err: HttpError | Error | string) {
    if (err instanceof HttpError) {
        console.log(err.status);      // HttpError
    } else if (err instanceof Error) {
        console.log(err.message);     // Error
    } else {
        console.log(err);             // string
    }
}

instanceof walks the prototype chain at runtime, so it requires an actual runtime constructor. If HttpError were a type alias or an interface, err instanceof HttpError would not compile, because there is nothing at runtime to check against. Two more runtime caveats: objects created in another realm (an iframe, a Node vm context) fail instanceof against your realm’s classes, and values that went through JSON.parse or structuredClone are plain objects, not class instances. For data that crossed a serialization boundary, use in or a predicate instead.

in

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

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

in checks for the presence of a property key at runtime—on the object itself or its prototype chain—regardless of the property’s value. That makes it the natural guard for plain-object unions, but it narrows by structural overlap, not by intent. Section 5 shows how that can mislead you. Since TypeScript 4.9, in also narrows values whose type does not mention the key at all: after if ("id" in obj) on an object, obj is treated as object & Record<"id", unknown>, which is handy when validating unknown input.

Truthiness

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

Truthiness narrowing removes null and undefined in the true branch, but it also treats "", 0, 0n, and NaN as false. Notice the false branch above: its type is still string | undefined, because the empty string reaches it too. When the empty string or zero is a valid value—a quantity field, an optional prefix—use an explicit != null check (next section) instead of a truthiness check. This is one of the most common real bugs that type checking does not catch, because both branches are type-correct.


Equality narrowing

Plain equality comparisons narrow too, and they are the right tool for separating null and undefined from real values.

function greetUser(name: string | null | undefined) {
    // Loose equality treats null and undefined as equal to each other
    // and to nothing else, so this one check eliminates both.
    if (name == null) {
        console.log("Anonymous visitor");
        return;
    }
    console.log(`Hello, ${name.toUpperCase()}`);   // name: string
}

== null is one of the few places where a linter’s “always use ===” rule is worth overriding deliberately (most configurations, including ESLint’s eqeqeq with the "null": "ignore" option, allow exactly this case). Writing name === null || name === undefined is equivalent but noisier, and writing !name has the empty-string problem from the previous section.

Comparing two variables narrows both to their common type:

function compare(a: string | number, b: string | boolean) {
    if (a === b) {
        // a and b can only be equal if both are strings
        a.toUpperCase();
        b.toUpperCase();
    }
}

Comparing a property against a literal narrows the whole object when that property is a discriminant, which leads to the most important pattern in this guide.


Discriminated unions and exhaustiveness checking

A discriminated union is a union of object types that share a property (the tag) whose type is a different literal in each member. Checking the tag narrows the whole object, including sibling properties that were not part of the comparison.

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

function assertNever(value: never): never {
    throw new Error(`Unhandled case: ${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;
        case "triangle":
            return (shape.base * shape.height) / 2;
        default:
            // Every case is handled, so shape is `never` here. If someone adds
            // a fourth variant and forgets a case, shape is no longer `never`
            // and this call becomes a COMPILE ERROR.
            return assertNever(shape);
    }
}

The assertNever helper works because its parameter type is never, the type with no possible values. When every case is handled, the default branch narrows shape down to never by elimination and the call type-checks. The moment a new variant is added to Shape without updating this switch, the unhandled variant leaks into default, and the build fails. This turns “we forgot to handle the new case” from a production bug into a compile error, which is why it is one of the highest-leverage patterns for reducers, state machines, and API response handlers that grow new variants over time. The runtime throw is a second line of defense for values that bypass the type system, such as unvalidated JSON.

If you prefer not to write a helper, shape satisfies never in the default branch (TypeScript 4.9+) gives the same compile-time check, and the @typescript-eslint/switch-exhaustiveness-check rule can enforce it across a codebase.

What makes a good discriminant: the tag must be a literal type (string, number, boolean, or null/undefined literal) that is different in each member. A tag typed as plain string in any member breaks narrowing for the whole union. boolean tags work ({ success: true; data: T } | { success: false; error: string }), but string tags scale better once there are more than two variants and read better in logs.

Destructuring: since TypeScript 4.6, destructuring the tag and the payload from the same parameter into const bindings keeps them correlated.

type Action =
    | { type: "add"; payload: number }
    | { type: "rename"; payload: string };

function reduce({ type, payload }: Action) {
    if (type === "add") {
        payload.toFixed();        // ✅ payload: number (TS 4.6+)
    }
}

The correlation is lost if you destructure only the tag (const { type } = action) and then read action.payload, or if the bindings are let. When in doubt, switch on action.type directly.


Custom guards: predicates and assertion functions

Type predicates

interface User { id: string; name: string }
interface Admin extends User { permissions: string[] }

function isAdmin(user: User | Admin): user is Admin {
    return "permissions" in user;
}

function greet(user: User | Admin) {
    if (isAdmin(user)) {
        console.log(`Admin ${user.name}: ${user.permissions.join(", ")}`);
    }
}

The user is Admin return annotation is a type predicate. A function that returns a plain boolean performs the same runtime work but gives the compiler nothing to narrow with: user would still be User | Admin inside the branch. The predicate tells the compiler “when this returns true, treat the argument as Admin”—and, just as importantly, “when it returns false, it is not an Admin.”

That power comes with a responsibility: TypeScript does not check that the body matches the claim. function isAdmin(u: User | Admin): u is Admin { return true; } compiles, and every branch that trusts it is now operating on unverified data. A predicate is a promise you make to the compiler, so keep its body obviously correct and unit-test it on its own. For validating untrusted input such as JSON.parse results, generating predicates from a schema (Zod, Valibot, and similar libraries) is safer than writing them by hand.

TypeScript 5.5 can infer predicates for simple arrow functions, which fixes a long-standing annoyance:

const ids = [1, undefined, 3];
const defined = ids.filter(id => id !== undefined);   // number[] in TS 5.5+ (was (number | undefined)[])

Assertion functions

An assertion function narrows by not returning when the check fails:

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

function handle(input: unknown) {
    assertIsString(input, "input");
    input.toUpperCase();     // input: string for the rest of the function
}

Assertion functions are useful at the top of a function that validates its inputs, because they avoid wrapping the whole body in an if. Two rules trip people up: an assertion function must be called through an explicitly typed reference (a function declaration or a const with an explicit type annotation—calling it through an untyped arrow-function variable gives error 2775), and like predicates, the compiler trusts the signature without checking the body.


Where narrowing is lost

Across closure boundaries

function schedule(value: string | number) {
    if (typeof value === "string") {
        setTimeout(() => {
            console.log(value.toUpperCase());   // ✅ TS 5.4+: value is string
        }, 0);
    }
}

function scheduleWithReassign(value: string | number) {
    if (typeof value === "string") {
        setTimeout(() => {
            // ❌ value: string | number — it is reassigned below,
            // so by the time the callback runs it might be a number
            // console.log(value.toUpperCase());
        }, 0);
    }
    value = 42;
}

A callback runs later, after other code may have changed the variable. Before TypeScript 5.4 the compiler conservatively threw away narrowing for every let variable and parameter inside a closure, so even the first function above was an error; many older answers online still describe that behavior. Since 5.4, narrowing is preserved when the compiler can see there are no assignments to the variable after the point where the closure is created. If there is any later assignment, as in the second function, the callback sees the declared type again. The fix is to copy the narrowed value into a const before creating the closure (const s = value;), which gives the compiler a binding that can never change.

Through property access on mutable objects

function render(state: { user?: { name: string } }) {
    if (state.user) {
        log();                     // could this mutate state.user? The compiler assumes no.
        state.user.name;           // still narrowed
    }
}

TypeScript narrows property accesses like state.user optimistically and does not invalidate the narrowing when you call a function in between, even though that function could set state.user = undefined. This is a deliberate trade-off for usability, and it means narrowing on mutable shared objects is a hint, not a guarantee. Copying to a local const user = state.user before the check makes the intent explicit and safe.

Structural lookalikes with in

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

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

const sub: Submarine = { swim() {}, dive() {} };
move(sub);   // compiles: Submarine is structurally assignable to Fish

The in check narrows by property presence alone, with no concept of which type the author intended. As codebases accumulate generic method names (close, dispose, send), in-based narrowing gets less reliable at telling your intended union members apart from incidental lookalikes. The durable fix is a dedicated tag property per member, as in section 4, instead of relying on whichever method happens to be unique among the union’s current members.

Type assertions

value as string is not narrowing. A guard is checked: the compiler narrows because it recognized a runtime check. An as assertion is unchecked: it tells the compiler “trust me,” and a wrong assertion only shows up as a crash somewhere downstream. Reach for a guard whenever the value’s shape is not already certain.


Where narrowing shows up next

Unions are where narrowing earns its keep, so union and intersection types is the natural companion to this post; interfaces and generics cover the shapes you will be narrowing between.



Frequently Asked Questions (FAQ)

Q. What’s the practical difference between a type guard and a plain runtime check?

A. Every type guard is a runtime check, but not every runtime check is a type guard. A guard is specifically a check the compiler is able to fold back into its control flow analysis—typeof, instanceof, in, truthiness, equality against null/undefined/a literal, or a function with an explicit value is T or asserts value is T signature. A helper that returns a plain boolean performs the same runtime work but gives the compiler nothing to narrow with (unless TypeScript 5.5+ can infer a predicate from a simple arrow function body).

Q. Why does my discriminated union stop narrowing after I destructure it?

A. Destructuring only the tag (const { kind } = shape) and then reading shape.radius breaks the link: narrowing kind does not narrow shape. Since TypeScript 4.6, destructuring the tag and the payload from the same value into const bindings keeps them correlated, so checking kind narrows the destructured payload. Otherwise, switch on shape.kind directly.

Q. Do as type assertions narrow types the same way a guard does?

A. No. A type guard is checked—the compiler narrows because it verified a recognized runtime pattern. An as assertion is unchecked and produces no error until a mismatched value crashes downstream. Reserve as for cases where you have external knowledge the type checker cannot see, such as a payload you have already validated by other means.