• typescript
  • types

Type narrowing is the skill, not the syntax

Learning TypeScript syntax is easy. Learning to narrow types correctly — and knowing when not to — takes longer.

Feb 25, 2026·6 min read

TypeScript's type narrowing — the process of going from a broad type to a specific one — looks mechanical at first. You write an if check, the type narrows, you move on. The syntax is simple.

The skill is knowing which narrowing to reach for and when narrowing reveals a design problem instead of solving one.

#The five ways to narrow

typeof — for primitives:

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

instanceof — for class instances:

function handleError(error: unknown) {
  if (error instanceof Error) {
    console.error(error.message); // error: Error
  }
}

in — for object shape checks:

type Cat = { meow: () => void };
type Dog = { bark: () => void };
 
function makeNoise(animal: Cat | Dog) {
  if ('meow' in animal) {
    animal.meow(); // animal: Cat
  } else {
    animal.bark(); // animal: Dog
  }
}

Discriminated unions — the pattern I use most:

type Result =
  | { status: 'ok'; data: User }
  | { status: 'error'; message: string };
 
function handle(result: Result) {
  if (result.status === 'ok') {
    console.log(result.data.name); // result: { status: 'ok'; data: User }
  } else {
    console.log(result.message);   // result: { status: 'error'; message: string }
  }
}

Type predicates — when none of the above fit:

function isUser(value: unknown): value is User {
  return (
    typeof value === 'object' &&
    value !== null &&
    'id' in value &&
    typeof (value as Record<string, unknown>).id === 'string'
  );
}

#Discriminated unions deserve more attention

The pattern is simple: add a literal field (usually type, status, or kind) that is unique to each branch. TypeScript uses it to narrow the entire object.

type Event =
  | { type: 'click'; x: number; y: number }
  | { type: 'keydown'; key: string }
  | { type: 'resize'; width: number; height: number };
 
function handle(event: Event) {
  switch (event.type) {
    case 'click':
      return handleClick(event.x, event.y);
    case 'keydown':
      return handleKey(event.key);
    case 'resize':
      return handleResize(event.width, event.height);
  }
}

The switch is exhaustive by construction. Add a new event type without handling it and TypeScript will tell you everywhere you missed it.

#Type assertions are not narrowing

const value = JSON.parse(data) as User;
value.name; // no error — but runtime can still explode

as User does not check anything. It is a promise to TypeScript that you know what the type is. If you are wrong, TypeScript trusts you anyway.

The distinction matters: narrowing proves a type to the compiler through control flow. Assertions claim a type without proof. Use assertions only when you have evidence the compiler cannot see — and document why.

#When narrowing reveals a bad type

Sometimes narrowing is painful because the type is wrong. If you find yourself narrowing string | number | boolean | null | { data: unknown } in every function that touches a value, the type is doing too much.

The fix is usually a discriminated union:

// painful to narrow everywhere
type ApiResponse = { data: unknown } | string | null;
 
// designed to narrow cleanly
type ApiResponse =
  | { status: 'success'; data: unknown }
  | { status: 'error'; message: string }
  | { status: 'loading' };

Frequent narrowing at the same locations is a signal that the type does not match the domain. Redesign the type; the narrowing becomes trivial.

#The unknown vs any question

When you receive data from an external source — API response, form input, localStorage — type it as unknown, not any.

any turns off type checking. unknown forces you to narrow before you use the value:

function processInput(input: unknown) {
  if (typeof input === 'string') {
    return input.trim();
  }
  throw new Error(`Expected string, got ${typeof input}`);
}

With any, a typo in a property name silently produces undefined. With unknown, TypeScript catches it before runtime.


Narrowing is how TypeScript proves to itself that your code is correct. Learning the syntax takes an afternoon. Learning to design types that narrow cleanly — and spotting when a type is fighting you instead of helping you — takes longer, and that is the part worth investing in.