Type Narrowing: How TypeScript Follows Your if Statements

October 4, 2026 · 3 min read

A value typed string | number | null could be any of three things. You can't call .toUpperCase() on it — that would crash for numbers and null. But you also shouldn't need a cast. You just need to check, the same way you would in plain JavaScript, and TypeScript follows along.

That's narrowing: the checker tracks your control flow and refines a variable's type on each line based on the checks you've made.

function format(value: string | number | null) {
if (value === null) return '—'
if (typeof value === 'number') {
return value.toFixed(2)
}
return value.toUpperCase()
}
type of value here
valuestring | number | null

value could be any of three types. A union type means "one of these, and you have to find out which before using it".

0 / 5

The checks that narrow

typeof — for primitives:

function pad(value: string | number) {
  if (typeof value === 'number') {
    return ' '.repeat(value);    // value: number
  }
  return value;                  // value: string
}

Equality — including against null and undefined:

function greet(name: string | null) {
  if (name === null) return 'Hello, stranger';
  return `Hello, ${name}`;       // name: string
}

Truthiness — handy, but careful: it also excludes 0 and ''.

function show(count?: number) {
  if (count) return `${count} items`;   // count: number, but 0 lands below!
  return 'No items';
}

in — for objects that differ by which properties they have:

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

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

instanceof — for class instances:

function message(err: unknown) {
  if (err instanceof Error) return err.message;   // err: Error
  return String(err);
}

Early returns narrow everything after them

Once a branch returns or throws, the possibilities it handled are gone for the rest of the function:

function format(value: string | number | null) {
  if (value === null) return '—';
  // value: string | number from here on
  if (typeof value === 'number') return value.toFixed(2);
  // value: string from here on
  return value.toUpperCase();
}

This "guard clause" style keeps the happy path unindented and gives the checker exactly what it needs.

Writing your own type guard

Sometimes the check is more complex than one operator. A function that returns value is Type teaches the checker a new kind of narrowing:

type User = { id: number; name: string };

function isUser(value: unknown): value is User {
  return (
    typeof value === 'object' &&
    value !== null &&
    typeof (value as User).id === 'number' &&
    typeof (value as User).name === 'string'
  );
}

const data: unknown = await res.json();
if (isUser(data)) {
  data.name;   // data: User
}

This is the honest way to handle outside data: start from unknown, prove its shape, and get a real type as a result. (Schema libraries like Zod generate guards like this for you.)

Filtering arrays

Type guards also fix a classic annoyance:

const values = ['a', null, 'b'];              // (string | null)[]
const strings = values.filter((v): v is string => v !== null);   // string[]

Newer TypeScript versions (5.5+) can infer simple guards like this one on their own, so values.filter((v) => v !== null) gives string[] too.

When narrowing doesn't stick

Narrowing applies to the variable at that point in the code. It's lost in places the checker can't be sure nothing changed — most often inside a callback that runs later:

function later(user: { name: string | null }) {
  if (user.name !== null) {
    setTimeout(() => {
      user.name.length;   // error: 'user.name' is possibly 'null'.
    });
  }
}

By the time the callback runs, user.name could have been reassigned. Copy it into a const first, and the narrowed type sticks:

const name = user.name;
if (name !== null) setTimeout(() => name.length);

The takeaway

Narrowing is why TypeScript feels like JavaScript: you write the checks you'd write anyway, and the type system gets more precise with each one. When you find yourself reaching for as, ask what check would prove it instead — that check is usually the missing bug fix.