Skip to content
TR

Phase 2 · Type system core · Lesson 2.2

Beginner

Narrowing

How TypeScript follows your `if` checks to refine a union into one specific type: typeof, truthiness, equality, `in`, instanceof, assignment, and the cases where narrowing is lost.

20 min

A union says "this could be one of several things". Sooner or later you need to do something that only works for one of them: call .toUpperCase() on the string, .getTime() on the date. Narrowing is how you get there. You write ordinary JavaScript checks, and TypeScript follows them to work out a more specific type in each branch. No casts, no special syntax.

Control-flow analysis

TypeScript reads your code the way the runtime will execute it: through every if, return, throw, switch and &&. At each point it tracks the narrowed type of each variable, which can be more specific than its declared type.

function padLeft(padding: number | string, input: string): string {
  if (typeof padding === "number") {
    return " ".repeat(padding) + input;
    //                ^? (parameter) padding: number
  }
  return padding + input;
  //     ^? (parameter) padding: string
}

After the if returns, the only way to reach the last line is if padding was not a number. TypeScript knows that, so padding is string there. This is called control-flow analysis, and every technique below is a way of feeding it information.

typeof guards

typeof returns one of eight strings at runtime, and TypeScript understands all of them: "string", "number", "bigint", "boolean", "symbol", "undefined", "object" and "function".

function toText(value: string | number | boolean | (() => string)): string {
  if (typeof value === "function") return value();
  if (typeof value === "number") return value.toFixed(2);
  if (typeof value === "boolean") return value ? "yes" : "no";
  return value;
  //     ^? (parameter) value: string
}

The typeof null trap

In JavaScript, typeof null === "object". It's a 30-year-old bug that can never be fixed. TypeScript models it faithfully, so a typeof check for "object" does not remove null:

function printAll(strs: string | string[] | null) {
  if (typeof strs === "object") {
    // strs: string[] | null  (null is an "object" too!)
    // @ts-expect-error -- 'strs' is possibly 'null'.
    for (const s of strs) console.log(s);
  } else if (typeof strs === "string") {
    console.log(strs);
  }
}

The fix is to rule out null explicitly, either with strs !== null or with a truthiness check, which we'll look at next.

Quick check

What is the type of x on the marked line?

function f(x: Date | string | null) {
  if (typeof x === "object") {
    x; // <- here
  }
}

Truthiness narrowing

if (value) removes everything that is always falsy: null, undefined, false, and literal types like 0 or "".

function printAll(strs: string | string[] | null) {
  if (strs && typeof strs === "object") {
    for (const s of strs) console.log(s); // strs: string[]
  } else if (typeof strs === "string") {
    console.log(strs);
  }
}

The pitfalls: 0, "" and NaN

Truthiness is a runtime check on the value, and some perfectly valid values are falsy: 0, -0, 0n, "" and NaN. The type system can't express "a non-empty string" or "a non-zero number", so here's what happens:

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

That one is usually fine: an empty name is as good as no name. But with numbers it's a real bug factory:

function setVolume(level: number | undefined) {
  // Bug: a volume of 0 is treated as "not set" and becomes 50.
  const bad = level || 50;
 
  // Correct: ?? only falls back on null or undefined.
  const good = level ?? 50;
  return { bad, good };
}

TypeScript accepts both lines. Types can't catch this one; you have to know it. Rule of thumb: use truthiness for objects and arrays, use !== undefined, != null or ?? for strings and numbers.

Equality narrowing

===, !==, == and != narrow too. If two values are strictly equal, they must share a type:

function compare(a: string | number, b: string | boolean) {
  if (a === b) {
    // The only type they have in common is string.
    return a.toUpperCase() + b.toLowerCase();
  }
  return "different";
}

Comparing against a literal narrows to that literal, and != null is the one place loose equality is idiomatic: it removes both null and undefined.

function len(text: string | null | undefined): number {
  if (text != null) {
    return text.length;
    //     ^? (parameter) text: string
  }
  return 0;
}

The in operator

For object types, "key" in obj narrows to the members that have (or might have) that property.

type Fish = { swim: () => void };
type Bird = { fly: () => void };
 
function move(animal: Fish | Bird) {
  if ("swim" in animal) {
    return animal.swim(); // animal: Fish
  }
  return animal.fly();    // animal: Bird
}

An optional property counts as "might have", so a member with swim?: ... would stay in both branches.

Since TypeScript 4.9, in also works when no member declares the key: it adds the property to the type. That's what makes it so useful for unknown, as you'll see at the end of this lesson.

instanceof

x instanceof C narrows to instances of the class (anything with a construct signature, actually).

function formatDate(value: Date | string): string {
  if (value instanceof Date) {
    return value.toISOString(); // value: Date
  }
  return value;                 // value: string
}

instanceof only works with things that exist at runtime: classes and constructor functions. You cannot use it with an interface or a type alias, because they're erased. x instanceof User where User is an interface is a compile error.

Array.isArray

function toList(input: string | string[]): string[] {
  if (Array.isArray(input)) {
    return input;   // input: string[]
  }
  return [input];   // input: string
}

One gotcha: Array.isArray is declared as isArray(arg: any): arg is any[]. On a union it narrows correctly, as above. On unknown it gives you any[], and any quietly turns checking off for the elements. Treat that result as unknown[] yourself.

Narrowing by assignment

Assigning to a variable narrows it to the type of what you assigned, within the declared type:

let id: string | number = "a-1";
const first = id;
//    ^? const first: string
 
id = 42;
const second = id;
//    ^? const second: number
 
// @ts-expect-error -- Type 'boolean' is not assignable to type 'string | number'.
id = true;

The declared type (string | number) is the contract for what you may assign. The narrowed type is what TypeScript knows right now.

Other things control flow understands

A few more patterns that narrow, which you'll use every day:

function area(shape: { kind: "circle"; r: number } | { kind: "square"; size: number }) {
  // Early return / throw: everything after knows the check failed
  if (shape.kind === "circle") return Math.PI * shape.r ** 2;
  return shape.size ** 2; // shape: the square
}
 
function assertString(value: unknown): asserts value is string {
  if (typeof value !== "string") throw new Error("Not a string");
}
 
function shout(input: unknown) {
  assertString(input);
  return input.toUpperCase(); // input: string after the assertion
}
 
// Since TS 5.5, simple filter callbacks become type predicates automatically
const ids = [1, null, 2].filter((x) => x !== null);
//    ^? const ids: number[]

Custom type guards (value is T) and assertion functions get their own lesson later; for now, know that they plug into the same control-flow analysis.

Quick check

Which line is a type error?

function show(count: number | undefined) {
  if (!count) {
    return count.toFixed(0);
  }
  const doubled = count * 2;
  console.log(doubled);
  return (count ?? 0).toFixed(0);
}

When narrowing is lost: callbacks and closures

Here is the gotcha that confuses experienced developers. Narrowing is a fact about a point in time. A callback runs later, and by then a mutable variable might have changed.

declare function getName(): string | null;
 
let current = getName();
if (current !== null) {
  setTimeout(() => {
    // @ts-expect-error -- 'current' is possibly 'null'.
    console.log(current.toUpperCase());
  }, 100);
}
current = null; // this is why: it may be null by the time the callback runs

TypeScript is right to complain: the timer fires after current = null, and the code would crash.

Since TypeScript 5.4, the compiler is smarter about this. If a let variable or parameter is not reassigned anywhere after the closure is created, the narrowing is kept inside the closure. Remove the last line above and the error disappears. But narrowing is still lost when:

  • the variable is reassigned after the closure (as above), or inside any nested function;
  • the closure is a hoisted function declaration rather than an arrow or function expression;
  • you narrowed a property (obj.value), not a variable, because any code could mutate the object in the meantime.
type Config = { label: string | null };
 
function render(config: Config, items: string[]) {
  if (config.label !== null) {
    return items.map((item) => {
      // @ts-expect-error -- 'config.label' is possibly 'null'.
      return config.label.toUpperCase() + item;
    });
  }
  return items;
}

The fix: a const copy

A const can never change, so its narrowing is always trusted inside closures. Copy the value into a const first:

type Config = { label: string | null };
 
function render(config: Config, items: string[]) {
  const label = config.label; // snapshot it
  if (label !== null) {
    return items.map((item) => label.toUpperCase() + item); // label: string
  }
  return items;
}
 
console.log(render({ label: "Item: " }, ["a", "b"]));

▶ Try it in the TypeScript Playground

This is also more honest code: the callback now uses the value you checked, not whatever the object holds later.

Narrowing unknown

unknown is the safe type for values you know nothing about: JSON.parse output, catch errors, messages from other windows. You can't do anything with an unknown until you narrow it, and every technique above works:

function describe(value: unknown): string {
  if (typeof value === "string") return `string of length ${value.length}`;
  if (typeof value === "number") return `number ${value}`;
  if (value instanceof Error) return `error: ${value.message}`;
  if (Array.isArray(value)) return `array of ${value.length}`;
  return "something else";
}

For objects, the recipe is: check it's a non-null object, then check each property with in, then check the property's type.

type User = { id: number; name: string };
 
function isUser(value: unknown): value is User {
  return (
    typeof value === "object" &&
    value !== null &&            // don't forget: typeof null === "object"
    "id" in value &&             // value: object & Record<"id", unknown>
    typeof value.id === "number" &&
    "name" in value &&
    typeof value.name === "string"
  );
}
 
const data: unknown = JSON.parse('{"id": 1, "name": "Ada"}');
if (isUser(data)) {
  console.log(data.name.toUpperCase()); // data: User
}

The in narrowing from TypeScript 4.9 is what lets value.id compile at all: after "id" in value, the type becomes object & Record<"id", unknown>, and then typeof value.id === "number" narrows the property.

Spot the error

This function is meant to return the first tag, or "none". It compiles in JavaScript. Why doesn't TypeScript accept it, and what's the fix?

function firstTag(tags: string[] | null): string {
  if (typeof tags === "object") {
    return tags[0] ?? "none";
  }
  return "none";
}
Show the answer

typeof null is "object", so inside the if the type is still string[] | null, and tags[0] fails with 'tags' is possibly 'null'. At runtime, firstTag(null) would throw. Check for null directly:

function firstTag(tags: string[] | null): string {
  if (tags !== null) {
    return tags[0] ?? "none";
  }
  return "none";
}

Or shorter: return tags?.[0] ?? "none";. (With default settings tags[0] is typed string, not string | undefined. Turn on noUncheckedIndexedAccess to make the ?? "none" genuinely necessary.)

Recap

  • Narrowing = TypeScript following your runtime checks (control-flow analysis) to refine a declared type in each branch.
  • typeof understands all eight results, but typeof x === "object" keeps null.
  • Truthiness removes null, undefined and false, but 0, "" and NaN are falsy too. Use ?? / != null for numbers and strings.
  • === narrows to the shared type; != null removes both null and undefined.
  • "key" in obj picks union members with that property; instanceof works only with runtime classes; Array.isArray narrows to arrays (but gives any[] on unknown).
  • Assignment narrows a variable within its declared type.
  • Narrowing is lost inside closures for reassigned lets, hoisted function declarations and object properties. Copy to a const.
  • unknown is narrowed with the same tools: typeof, instanceof, !== null plus in for objects.

Interview cards

1 / 8