JS Ledger

Type-Level Narrowing: How TypeScript Decides What You Meant

Control flow analysis is a graph walk over your code. Knowing what widens a type again explains most of the errors that look wrong.

6 min read
Machined steel guide rails on a workbench, adjustable stops stepping inward to leave a single narrow channel, with one brass bushing on the clamp that sets the width.

You check that a value is not null, and two lines later TypeScript says it might be null. Nothing reassigned it. The error looks like a bug in the compiler, and it almost never is.

The rules that produce it are consistent and worth learning as rules, because they explain a whole category of errors at once instead of one at a time.

Every example below was run against TypeScript 5.9.3 with strict on; where the checker’s answer is surprising, it is the checker’s actual answer.

Narrowing is a fact about a place, not a variable

TypeScript builds a control flow graph for every function: nodes for statements, edges for the paths execution can take. Narrowing walks that graph and records, at each point, what is known about each reference.

That word matters. The fact is attached to user.profile at line 12, not to user.profile in general.

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

The checker knows which edge it arrived on. On the true branch, typeof value === 'string' held; on the false branch, it did not, and string | number minus string leaves number. Exhaustiveness checking is the same mechanism — remove every member and the remaining type is never.

Narrowing constructs are a closed list: typeof, instanceof, in, equality against a literal or against null/undefined, truthiness, Array.isArray, discriminant property comparison, and user-defined type predicates. Anything else is an ordinary expression the checker will not read into.

The thing that does not discard it

Start with what most people expect, because TypeScript does not do it.

interface Config { name: string | null }
declare function log(message: string): void;

function render(config: Config) {
	if (config.name === null) return;

	log('rendering');
	config.name.toUpperCase();   // no error
	config.name = null;          // ...even with this two lines below
}

log could reassign config.name. So could anything else holding a reference to that object. TypeScript narrows anyway, and holds the narrowing across the call, across an await, and even when it can see an assignment to that property later in the same function.

This is unsound, and deliberately so: discarding every property narrowing at every call was judged to produce more false alarms than caught bugs. Worth knowing, because it is the one place the checker will cheerfully agree with code that throws at runtime. If a property really can be mutated underneath you, copying it to a local is not a style preference — it is the only thing making the check mean anything.

Where it really is discarded

Two situations, and they are narrower than the folklore.

Inside a closure

function render(config: Config) {
	if (config.name === null) return;

	setTimeout(() => {
		config.name.toUpperCase();   // Error: possibly null
	});
}

The callback runs later, and the checker has no idea when. A property access always loses its narrowing here, and marking the property readonly does not help — the error is identical.

A plain variable is treated more precisely: it keeps its narrowing inside a closure unless it is assigned somewhere in the enclosing function.

function process(value: string | null) {
	if (value === null) return;
	setTimeout(() => value.toUpperCase());   // fine — nothing reassigns `value`
}

function processAgain(value: string | null) {
	if (value === null) return;
	setTimeout(() => value.toUpperCase());   // Error: possibly null
	value = null;                            // ...because of this line
}

The assignment does not have to run before the callback, or at all. Its presence anywhere in the function is enough.

One move fixes every case: copy to a const before the closure.

const name = config.name;
if (name === null) return;
setTimeout(() => name.toUpperCase());   // fine

An element access with a reassigned key

declare const record: Record<string, string | undefined>;

function read(keys: string[]) {
	let key = keys[0];
	if (record[key] !== undefined) {
		record[key].toUpperCase();   // Error: possibly undefined
	}
	key = keys[1];                   // ...because of this
}

Since TypeScript 4.7, record[key] narrows when the key is effectively constant — a const, or a let that is never reassigned. Reassign it anywhere in the function and the two accesses stop being the same reference, so the fact does not transfer.

Making the checker follow you

Three tools extend narrowing past what the built-in constructs cover.

Type predicates let a function report a narrowing to its caller:

function isString(value: unknown): value is string {
	return typeof value === 'string';
}

Since TypeScript 5.5 this is often unnecessary — a function whose body is plainly a type test gets an inferred predicate. Writing it explicitly still matters when the body is not obvious, and it remains an unchecked assertion: the compiler trusts the annotation and will not verify that the body agrees with it.

Assertion functions narrow by throwing:

function assertDefined<T>(value: T): asserts value is NonNullable<T> {
	if (value == null) throw new Error('unexpected nullish value');
}

assertDefined(config.name);
config.name.toUpperCase();    // narrowed from here on

These require an explicit type annotation on the calling side — a const with an inferred type will not carry the assertion.

Discriminated unions are the construct the whole system is built around:

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

function handle(result: Result) {
	if (result.status === 'ok') return result.data;
	return result.message;
}

Since TypeScript 4.6 the narrowing also survives destructuring — but only when every member carries the property being destructured:

type Action =
	| { kind: 'number'; payload: number }
	| { kind: 'string'; payload: string };

function f(action: Action) {
	const { kind, payload } = action;
	return kind === 'number' ? payload.toFixed(2) : payload.toUpperCase();
}

Pull out a property that only one member has — data from the Result above — and it does not compile at all, narrowing aside.

satisfies, and what it actually keeps

An annotation replaces the variable’s type with the one you wrote. satisfies checks against it and leaves the inferred type alone. The consequences are more specific than the usual summary suggests.

const routes: Record<string, string> = {
	home: '/',
	blog: '/blog/',
};

routes.home;      // string
routes.about;     // also string — the index signature accepts any key
const routes = {
	home: '/',
	blog: '/blog/',
} satisfies Record<string, string>;

routes.home;      // string — still widened, see below
routes.about;     // Error: Property 'about' does not exist

What satisfies preserves here is the key set: the inferred type is { home: string; blog: string }, so a typo in a lookup is a compile error and keyof typeof routes is a useful union. The annotation threw that away the moment the index signature took over.

What it does not preserve is the literal '/'. Inference still widens string literals in a mutable object, and the contextual type Record<string, string> asks for nothing narrower. To keep the values too, say so:

const routes = {
	home: '/',
	blog: '/blog/',
} as const satisfies Record<string, string>;

routes.home;      // '/'

The rule that follows: annotate when the wider type is the contract you want to publish; satisfies when you want the check and the key set; add as const when the values themselves carry meaning — which, for route maps and lookup tables, they usually do.

Where narrowing does not reach

Two limits are structural rather than incidental, and no amount of restructuring removes them.

A generic type parameter does not narrow. Inside a function generic over T, checking typeof value === 'string' narrows the value to T & string, but T itself is still whatever the caller chose. Code that tries to branch on a type parameter and return a different type per branch is usually reaching for overloads or conditional types instead.

Array element access is unchecked unless you ask for it. items[10] is typed as the element type even when the array is empty, because the alternative was judged too noisy for existing codebases. noUncheckedIndexedAccess in tsconfig.json adds undefined to every indexed read, which is correct and genuinely does add work — turn it on in a new project, and expect a long afternoon in an old one.

None of this runs at build time in a typical dev setup, which is worth being explicit about: the dev server strips types without checking them, so tsc --noEmit has to be its own step in a pipeline that already runs npm ci.

Reading errors as evidence

“Possibly undefined” after a check you know you wrote is worth reading as a claim about reachability, not about your competence. The checker is saying it lost track of the reference — because a closure captured it, or because the key that selected it can move.

The fix is almost never a non-null assertion. It is to give the fact a place to live: copy to a const before the closure, hoist the element access out of the loop, move the check closer to the use, or model the states as a discriminated union so the impossible combination cannot be represented at all.

And in the other direction: where the checker stays quiet across a function call it could not possibly have analysed, that silence is a decision about ergonomics, not a proof. The const copy is what turns it into one.

Each of those makes the code easier for a human to reason about too, which is the argument for treating the checker’s complaints as evidence rather than as obstacles.

Share

Related posts

Arrow keys to move, Enter to open.