codejump

AcademyTypeScript

Narrowing: how a union becomes one type

The checks TypeScript understands — typeof, instanceof, in, truthiness, discriminants — and what makes a narrowing silently fall apart again.

3 min readUpdated

3 exercises

Waiting at the end of this page.

The compiler follows your control flow

A union is a value that is one of several things. Narrowing is how you get from the union to the one thing, and TypeScript does it by reading the same checks you would write anyway:

TypeScript
function print(value: string | number) {
  if (typeof value === 'string') {
    value.toUpperCase();   // string here
  } else {
    value.toFixed(2);      // number here
  }
}

Nothing was annotated. The typeof check is also a type-level statement, and the compiler tracks it through the branches. Five checks do this work:

CheckNarrows
typeof x === 'string'primitives
x instanceof Errorclasses
'user' in payloadby property presence
if (x)removes null, undefined, '', 0, false
x.kind === 'circle'a discriminated union

Truthiness narrows more than you asked for

if (value) is the one that bites. It removes null and undefined — and also the empty string and zero:

TypeScript
function greet(name: string | null) {
  if (!name) return 'Hello, stranger';  // also catches ''
  return 'Hello, ' + name;
}

function label(count: number | undefined) {
  if (!count) return 'none';  // 0 is "none" now. Probably not what you meant.
  return String(count);
}

When the union is with null or undefined and the other member can be falsy, compare explicitly: if (count === undefined). The type is the same either way; the behaviour is not.

Discriminated unions are the ones worth designing

Anything narrows, but a union with a shared literal field narrows perfectly — and the compiler can then tell you when you missed a case:

TypeScript
type Result =
  | { status: 'ok'; data: string }
  | { status: 'error'; message: string };

function render(result: Result) {
  if (result.status === 'ok') {
    return result.data;      // 'error' branch has no data — and it is gone here
  }
  return result.message;
}

The field can be any literal type — a string, a boolean, a number. What makes it work is that the field exists on every member with a different literal value, so one comparison eliminates all the others. Design your states this way and most of your undefined checks disappear, because the impossible combinations are no longer representable.

When the check is not something the compiler can read

Sometimes the test is a function, and the compiler has no way to know that a true return means something about the argument. A type predicate says so:

TypeScript
function isUser(value: unknown): value is User {
  return typeof value === 'object' && value !== null && 'email' in value;
}

if (isUser(payload)) {
  payload.email;  // User
}

value is User is a promise the compiler cannot verify — the body could return true for anything. That is the trade: you get narrowing across a function call, and you own the correctness of that one function. Keep them small enough to read in one go.

What un-narrows a value

A narrowing is tied to a place in the control flow, and three things throw it away:

  • A function call in between. The compiler assumes any call could have reassigned a mutable variable it closes over.
  • `let` where `const` would do. A const narrows once and stays narrowed; a let is re-widened after anything that could write to it.
  • Reading through an index or a getter. obj.items[0] is a fresh read each time — narrow it into a local first.
TypeScript
if (state.user) {
  await save();
  state.user.email;  // Error: 'state.user' is possibly 'null'
}

const user = state.user;   // narrow into a local
if (user) {
  await save();
  user.email;              // fine
}

That error is not the compiler being pedantic. Between those two lines, anything could have set state.user to null — and the version that copies into a local is also the version that does not read a moving target twice.

Now practice it

2 challenges · 1 quiz

Reading this page is the cheap half. These are the exercises that make you use it.