
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.