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
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
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
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'.
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
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
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
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
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
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
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
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'.
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
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
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'.
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
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.
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
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
- Write a function taking
string | string[] | nullthat returns a single string, handling all three cases. UseArray.isArrayand a null check. - Write a function taking
number | undefinedthat returns the number doubled, or0if absent. Do it with truthiness first, then check what happens for input0, then fix it. - Reproduce the callback trap: narrow an optional property, then use it inside a
forEach. Read the error, then fix it by copying to aconst. - Take a nested optional shape like
{ a?: { b?: { c?: string } } }and readcsafely. Do it once with nestedifstatements and once with optional chaining, and decide which you would rather maintain. - 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 nullis'object', and truthiness discards0and''. Both cause real bugs, so use!= nullwhen those values are legitimate.- A narrowing does not survive into a callback, because the property could change first. Copy the value into a
constand narrow that. !silences the compiler without protecting anything at run time. It relocates the failure rather than preventing it.