Type Narrowing

When a value could be one of several types, TypeScript follows your if statements, switch cases and early returns to work out which type it must be at each point in the code. This is called narrowing, and it's how unions become usable.

typeof narrowing

TS typeof.ts
function pad(value: string | number, width: number): string {
  if (typeof value === "number") {
    return value.toFixed(2).padStart(width);
  }
  return value.padStart(width);
}

console.log(`[${pad(3.14159, 8)}]`);
console.log(`[${pad("hi", 5)}]`);
Output
[    3.14]
[   hi]

Inside the if, value is a number. After the return, TypeScript knows the number case is gone, so value must be a string. typeof can check for "string", "number", "bigint", "boolean", "symbol", "undefined", "object" and "function".

Watch out: typeof null is "object" — a long-standing JavaScript quirk. TypeScript knows this, so after typeof x === "object" the type still includes null until you check for it separately.

Truthiness narrowing

TS truthy.ts
function greet(name?: string | null) {
  if (name) {
    return `Hi ${name.trim()}`;
  }
  return "Hi there";
}

console.log(greet("  Lu "), "|", greet(), "|", greet(""));
Output
Hi Lu | Hi there | Hi there

An if (name) check removes null and undefined — but also the empty string, 0 and false, which may not be what you want. When those are valid values, check name != null instead.

Equality narrowing

TS equality.ts
type Unit = "px" | "em" | "%";

function css(value: number, unit: Unit | null) {
  if (unit === null) return String(value);
  if (unit === "%") return `${value * 100}%`;
  return `${value}${unit}`;
}

console.log(css(0.5, "%"), css(12, "px"), css(3, null));
Output
50% 12px 3

instanceof narrowing

TS instanceof.ts
function describe(when: Date | string) {
  if (when instanceof Date) {
    return when.toISOString().slice(0, 10);
  }
  return when;
}

console.log(describe(new Date(Date.UTC(2026, 4, 1))), describe("tomorrow"));
Output
2026-05-01 tomorrow

instanceof works for anything created by a class or constructor function: Date, Error, Map, your own classes.

The in operator

For plain objects, checking whether a property exists narrows to the union members that have it:

TS in.ts
type Fish = { swim: () => string };
type Bird = { fly: () => string };

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

console.log(move({ swim: () => "splash" }), move({ fly: () => "flap" }));
Output
splash flap

Narrowing with switch

TS switch.ts
function size(value: string | number | boolean): number {
  switch (typeof value) {
    case "string":
      return value.length;
    case "number":
      return value;
    default:
      return value ? 1 : 0;
  }
}

console.log(size("hello"), size(7), size(true));
Output
5 7 1

Control flow analysis

TypeScript tracks types through assignments and early exits too. Each time you assign a value, the variable's type narrows to what you assigned:

TS flow.ts
let id: string | number = "abc";
console.log(id.toUpperCase());

id = 42;
console.log(id.toFixed(1));

function process(input: string | undefined) {
  if (!input) throw new Error("input required");
  return input.split(",");
}
console.log(process("a,b"));
Output
ABC
42.0
[ 'a', 'b' ]

After the throw in process, TypeScript knows input must be a non-empty string, because the only other path left the function.

Array.isArray

TS is-array.ts
function toList(input: string | string[]): string[] {
  return Array.isArray(input) ? input : input.split(" ");
}

console.log(toList("a b c"), toList(["x"]));
Output
[ 'a', 'b', 'c' ] [ 'x' ]

Try it yourself

Try it yourself
Console output
Next: built-in checks only go so far. The next lesson shows how to write your own type guard functions and how discriminated unions make narrowing almost automatic.