Emmanuel Akinfulubi

Lesson 05 of 1818 min read

Narrowing

How the compiler follows an if statement. Once you see that it reads control flow the way you do, unions stop being awkward.

By the end: Take a union apart safely, and know which checks the compiler trusts.

Lesson 04 left you with a union you could not use. This lesson is how you use it.

Narrowing is the compiler reading your control flow. You write an ordinary if; the compiler notices what that if proves and, inside the branch, treats the value as the narrower type. Nothing about the runtime behaviour is different from JavaScript you would already have written. What changes is that the compiler follows along.

Once you see that it reads code the way you do, unions stop being awkward and start being obvious.

The compiler is following your logic

A check, and the type changes inside the branch

function describe(value: string | number): string {
  if (typeof value === 'string') {
    // In here, value is a string. Not a union — a string. So every string
    // method is available and the compiler is certain about it.
    return `text of length ${value.length}: ${value.toUpperCase()}`;
  }

  // And out here it is a number, because the only other possibility is
  // number and the branch above returned.
  return `number: ${value.toFixed(2)}`;
}

console.log(describe('hello'));
console.log(describe(3.14159));

Output


      

The checks the compiler understands

typeof, for primitives

typeof narrows the primitive types

function stringify(value: string | number | boolean | undefined): string {
  if (typeof value === 'string') return `string: ${value.trim()}`;
  if (typeof value === 'number') return `number: ${value.toFixed(1)}`;
  if (typeof value === 'boolean') return `boolean: ${value ? 'yes' : 'no'}`;
  return 'nothing at all';
}

console.log(stringify('  padded  '));
console.log(stringify(42));
console.log(stringify(false));
console.log(stringify(undefined));

Output


      

The typeof 'object' trap

TS18047
interface Point {
  x: number;
  y: number;
}

function sumOf(value: Point | null): number {
  if (typeof value === 'object') {
    // This branch looks like it holds a Point. It does not: typeof null is
    // also 'object', so null is still in play and the compiler says so.
    return value.x + value.y;
  }
  return 0;
}

console.log(sumOf({ x: 1, y: 2 }));

What the compiler says

error TS18047: 'value' is possibly 'null'.
error TS18047: 'value' is possibly 'null'.

Checking for null first, which is what you wanted

interface Point {
  x: number;
  y: number;
}

function sumOf(value: Point | null): number {
  // Truthiness excludes null, so this branch really does hold a Point.
  if (value) return value.x + value.y;
  return 0;
}

console.log(sumOf({ x: 1, y: 2 }));
console.log(sumOf(null));

Output


      

Truthiness, with a caveat

Truthiness narrows, and throws away more than you might mean

function greet(name: string | null | undefined): string {
  // One check removes null AND undefined, which is usually what you want.
  if (name) return `Hello, ${name}`;
  return 'Hello, stranger';
}

console.log(greet('Ada'));
console.log(greet(null));
console.log(greet(undefined));

// But it also removes the empty string, because '' is falsy. Sometimes that
// is correct; sometimes it is a bug you will spend an afternoon on.
console.log(greet(''));

Output


      

The precise checks, and what each one removes

function report(count: number | null | undefined): string {
  if (count === undefined) return 'not provided';
  if (count === null) return 'explicitly empty';
  return `count: ${count}`;
}

console.log(report(undefined));
console.log(report(null));
console.log(report(0));

// != null removes both null and undefined in one check, and keeps 0.
function total(n: number | null | undefined): number {
  return n != null ? n : -1;
}

console.log(total(0), total(null), total(undefined));

Output


      

Equality against literals

Comparing to a literal narrows a union of literals

type Level = 'debug' | 'info' | 'error';

function colourOf(level: Level): string {
  if (level === 'error') return 'red';
  // level is now 'debug' | 'info'
  if (level === 'debug') return 'grey';
  // and now it is exactly 'info'
  return 'blue';
}

console.log(colourOf('error'), colourOf('debug'), colourOf('info'));

// It works in both directions: comparing two unions narrows both.
function sameLevel(a: Level, b: Level): string {
  if (a === b) return `both ${a}`;
  return `${a} and ${b} differ`;
}

console.log(sameLevel('info', 'info'));
console.log(sameLevel('info', 'error'));

Output


      

The in operator, for optional properties

in narrows by asking whether a property exists

type Guest = { name: string };
type Member = { name: string; memberId: string };

function label(person: Guest | Member): string {
  if ('memberId' in person) {
    // Definitely a Member here.
    return `${person.name} (#${person.memberId})`;
  }
  return `${person.name} (guest)`;
}

console.log(label({ name: 'Ada' }));
console.log(label({ name: 'Grace', memberId: 'M-7' }));

Output


      

instanceof, for classes

instanceof narrows to a class

class HttpError extends Error {
  constructor(public status: number, message: string) {
    super(message);
    this.name = 'HttpError';
  }
}

class TimeoutError extends Error {
  constructor(public afterMs: number) {
    super(`timed out after ${afterMs}ms`);
    this.name = 'TimeoutError';
  }
}

function explain(error: Error): string {
  if (error instanceof HttpError) return `HTTP ${error.status}: ${error.message}`;
  if (error instanceof TimeoutError) return `Too slow (${error.afterMs}ms)`;
  return `Unknown: ${error.message}`;
}

console.log(explain(new HttpError(404, 'not found')));
console.log(explain(new TimeoutError(3000)));
console.log(explain(new Error('something else')));

Output


      

Array.isArray

Narrowing an array out of a union

function flatten(input: string | string[]): string {
  if (Array.isArray(input)) return input.join(', ');
  return input;
}

console.log(flatten('single'));
console.log(flatten(['a', 'b', 'c']));

// A common real signature: accept one or many.
function normalise(input: number | number[]): number[] {
  return Array.isArray(input) ? input : [input];
}

console.log(normalise(1));
console.log(normalise([1, 2]));

Output


      

Where narrowing gives up

Now the part that causes real frustration. Narrowing is flow analysis, and flow analysis has limits. Knowing where they are turns “the compiler is being stupid” into “of course, I moved the check away from the use”.

A narrowing does not survive a function call

The compiler cannot know the callback runs immediately

TS18048
interface Box {
  label?: string;
}

function print(box: Box): void {
  if (box.label) {
    // Proven non-empty here. But the arrow function below might be stored
    // and called later, after something else has cleared box.label, so the
    // compiler refuses to carry the narrowing inside it.
    [1, 2].forEach(() => {
      console.log(box.label.toUpperCase());
    });
  }
}

print({ label: 'ok' });

What the compiler says

error TS18048: 'box.label' is possibly 'undefined'.

The fix: put the narrowed value in a const

interface Box {
  label?: string;
}

function print(box: Box): void {
  const label = box.label;
  if (label) {
    // `label` is a local const that nothing can reassign, so the narrowing
    // holds everywhere, including inside callbacks.
    [1, 2].forEach((n) => console.log(n, label.toUpperCase()));
  }
}

print({ label: 'ok' });

Output


      

let and reassignment

Narrowing resets when a variable is reassigned

function process(input: string | number): string {
  let value: string | number = input;

  if (typeof value === 'string') {
    console.log('narrowed to string:', value.toUpperCase());
  }

  // Reassignment discards what the compiler knew, which is correct.
  value = 42;
  console.log('after reassignment:', typeof value);

  return String(value);
}

console.log(process('hello'));

Output


      

Nested property access

Narrowing one level does not narrow the next

TS18048
interface Config {
  server?: { host?: string };
}

function hostOf(config: Config): string {
  if (config.server) {
    // config.server is proven, but config.server.host is a separate optional
    // and has not been checked.
    return config.server.host.toUpperCase();
  }
  return 'none';
}

console.log(hostOf({ server: { host: 'localhost' } }));

What the compiler says

error TS18048: 'config.server.host' is possibly 'undefined'.

Optional chaining handles the whole path at once

interface Config {
  server?: { host?: string };
}

function hostOf(config: Config): string {
  // ?. short-circuits to undefined at the first missing link, and ?? supplies
  // the fallback. One expression instead of two nested checks.
  return config.server?.host?.toUpperCase() ?? 'none';
}

console.log(hostOf({ server: { host: 'localhost' } }));
console.log(hostOf({ server: {} }));
console.log(hostOf({}));

Output


      

Narrowing in the other direction

Early returns narrow everything after them

type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'square'; side: number };

function area(shape: Shape): number {
  // Handle one case and leave. Everything below this line knows the value is
  // not a circle, so `shape` is a square without any further checking.
  if (shape.kind === 'circle') return Math.PI * shape.radius ** 2;

  return shape.side ** 2;
}

console.log(area({ kind: 'circle', radius: 1 }).toFixed(2));
console.log(area({ kind: 'square', side: 3 }));

// Guard clauses at the top of a function are the same idea, and they are why
// that style reads so well in TypeScript specifically.
function initial(name: string | null): string {
  if (name === null) return '?';
  if (name.length === 0) return '?';
  return name[0]!.toUpperCase();
}

console.log(initial('Ada'), initial(''), initial(null));

Output


      

The non-null assertion, and why to distrust it

! after an expression tells the compiler “this is not null or undefined, take my word for it”. It is occasionally the right answer and frequently a way of postponing a crash.

What ! does, and what it does not do

const values: number[] = [10, 20, 30];

// With noUncheckedIndexedAccess, values[0] is number | undefined, because the
// compiler cannot know the array is non-empty. The ! overrides that.
const first = values[0]!;
console.log('first:', first + 1);

// The honest version, which handles the case the compiler was asking about.
const maybe = values[0];
console.log('checked:', maybe === undefined ? 'empty array' : maybe + 1);

// And here is what ! actually costs you. It is erased, so it provides no
// runtime protection whatsoever.
const empty: number[] = [];
const nothing = empty[0]!;
console.log('typed as number, actually:', nothing);
console.log('so this is NaN, silently:', nothing + 1);

Output


      

Putting it together

A function that narrows several ways, and what it compiles to

type Input =
  | { source: 'literal'; value: string }
  | { source: 'array'; values: string[] }
  | { source: 'missing' };

function render(input: Input, separator?: string): string {
  const sep = separator ?? ', ';

  switch (input.source) {
    case 'literal':
      return input.value.trim();
    case 'array':
      return input.values.filter((v) => v.length > 0).join(sep);
    case 'missing':
      return '(nothing)';
  }
}

console.log(render({ source: 'literal', value: '  padded  ' }));
console.log(render({ source: 'array', values: ['a', '', 'b'] }));
console.log(render({ source: 'array', values: ['x', 'y'] }, ' | '));
console.log(render({ source: 'missing' }));
What this compiles to
function render(input, separator) {
    const sep = separator ?? ', ';
    switch (input.source) {
        case 'literal':
            return input.value.trim();
        case 'array':
            return input.values.filter((v) => v.length > 0).join(sep);
        case 'missing':
            return '(nothing)';
    }
}
console.log(render({ source: 'literal', value: '  padded  ' }));
console.log(render({ source: 'array', values: ['a', '', 'b'] }));
console.log(render({ source: 'array', values: ['x', 'y'] }, ' | '));
console.log(render({ source: 'missing' }));

Output


      

Open the compiled output and notice how little is there. Every narrowing above was the compiler reasoning about code you would have written anyway; none of it costs a single runtime check that was not already needed.

Your turn

  1. Write a function taking string | string[] | null that returns a single string, handling all three cases. Use Array.isArray and a null check.
  2. Write a function taking number | undefined that returns the number doubled, or 0 if absent. Do it with truthiness first, then check what happens for input 0, then fix it.
  3. Reproduce the callback trap: narrow an optional property, then use it inside a forEach. Read the error, then fix it by copying to a const.
  4. Take a nested optional shape like { a?: { b?: { c?: string } } } and read c safely. Do it once with nested if statements and once with optional chaining, and decide which you would rather maintain.
  5. Find a ! in code you have written, or write one, and replace it with a real check. Note whether the check revealed a case you had not thought about.

Recap

  • Narrowing is the compiler reading ordinary control flow. You write the checks you would have written anyway; it keeps track of what they prove.
  • The checks it understands: typeof, truthiness, equality against literals, in, instanceof, Array.isArray, and a discriminant property.
  • typeof null is 'object', and truthiness discards 0 and ''. Both cause real bugs, so use != null when those values are legitimate.
  • A narrowing does not survive into a callback, because the property could change first. Copy the value into a const and narrow that.
  • ! silences the compiler without protecting anything at run time. It relocates the failure rather than preventing it.

Saved in this browser only.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.