JS / TS

Type Narrowing in TypeScript: Giving the Compiler the Right Clues

3 min read

Early TypeScript frustration has a signature sound: "I know this is a string here, why is the compiler yelling?" The answer is almost always narrowing — or rather, not giving the compiler the evidence it needs to narrow. Once you see how narrowing works, the relationship flips: instead of fighting the checker, you feed it clues and it does the paranoid bookkeeping for you.

TypeScript is smarter than you think

The compiler doesn't just check declared types; it tracks types through control flow. Inside an if that eliminates possibilities, the type literally changes:

function describe(value: string | number) {
  // here: value is string | number
  if (typeof value === 'string') {
    // here: value is string — .toUpperCase() is fine
    return value.toUpperCase();
  }
  // here: value is number — TS knows string was handled above
  return value.toFixed(2);
}

No casts, no assertions. The typeof check isn't just runtime code — it's evidence the compiler reads. Narrowing is the art of writing checks the compiler recognizes as evidence. Three cover nearly everything.

Clue 1: typeof — for primitives

typeof narrows among primitives: 'string', 'number', 'boolean', 'undefined', 'object', 'function'. It's the tool for "this argument can be several basic things":

function toDate(input: string | number | Date) {
  if (typeof input === 'string') return new Date(input); // string
  if (typeof input === 'number') return new Date(input); // number (timestamp)
  return input; // all that's left: Date
}

Note the last line: no check needed. Elimination is narrowing — TypeScript knows what remains. The everyday special case is truthiness for null/undefined: if (!user) return; narrows User | undefined to User for the rest of the function — the pattern strict mode makes you internalize.

Clue 2: instanceof — for class instances

typeof calls every object 'object', so for class instances you need instanceof:

function logError(err: unknown) {
  if (err instanceof Error) {
    console.error(err.message, err.stack); // narrowed to Error
    return;
  }
  console.error('Non-error thrown:', err);
}

This exact function appears in some form in every codebase I work on, because catch gives you unknown — and instanceof Error is the sanctioned way back to something useful. Same tool for x instanceof Date, x instanceof Response, and your own classes.

Clue 3: A shared discriminant property — for object shapes

Plain object types (API payloads, state objects) have no class to instanceof and are all 'object' to typeof. The pattern: give every variant a shared literal property, then narrow on it:

type PaymentMethod =
  | { kind: 'card'; last4: string }
  | { kind: 'bank'; iban: string }
  | { kind: 'paypal'; email: string };
 
function label(method: PaymentMethod): string {
  switch (method.kind) {
    case 'card':
      return `Card ending ${method.last4}`; // TS knows last4 exists here
    case 'bank':
      return `Bank ${method.iban.slice(0, 4)}…`;
    case 'paypal':
      return `PayPal (${method.email})`;
  }
}

Checking one property teaches the compiler the whole shape. This is the discriminated union pattern — simple here, and powerful enough that it gets its own post.

Putting it together

Real functions often need more than one clue. A parser accepting several input forms:

type RawEvent = string | Date | { kind: 'scheduled'; at: string } | null;
 
function eventDate(input: RawEvent): Date | undefined {
  if (!input) return undefined;                    // eliminates null
  if (typeof input === 'string') return new Date(input); // string handled
  if (input instanceof Date) return input;         // Date handled
  return new Date(input.at);                       // only the object remains —
}                                                  // .at is fully typed

Four input shapes, zero casts, and every branch's body is checked against exactly the type that can reach it. Delete the instanceof line and the last line errors — the compiler is genuinely tracking the elimination.

The takeaway

When TypeScript "doesn't believe you," it's telling you your certainty exists in your head but not in the code. Don't reach for as — that silences the checker precisely where you need it. Write the check: typeof for primitives, instanceof for class instances, a discriminant property for object shapes. The checks are code you should have anyway; TypeScript just rewards you for writing them.

Found this useful?

Share

I post shorter takes on javascript & typescript and frontend leadership on LinkedIn — follow along there, or get in touch if you're working on something related.