# 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.

- Published: 2026-08-19
- Tags: typescript, javascript
- Source: https://jsledger.com/blog/type-level-narrowing-in-typescript/
- Language: en-US
- Author: Jonah Vail

---
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.

```ts
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.

```ts
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

```ts
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.

```ts
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.

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

### An element access with a reassigned key

```ts
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](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-7.html),
`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:

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

Since [TypeScript 5.5](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-5.html)
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:

```ts
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:

```ts
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](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-6.html)
the narrowing also survives destructuring — but only when
every member carries the property being destructured:

```ts
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.

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

routes.home;      // string
routes.about;     // also string — the index signature accepts any key
```

```ts
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:

```ts
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](/blog/what-happens-when-vite-serves-a-module/), so `tsc --noEmit` has to
be its own step in a pipeline that already runs
[`npm ci`](/blog/reading-a-lockfile/).

## 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.
