- Book: TypeScript Essentials
- Also by me: The TypeScript Library — the 5-book collection
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You write the check: if (status). On the next line you pass an arrow function to setTimeout, and inside it the editor underlines status.length with 'status' is possibly 'undefined'. You just checked. The compiler saw you check. It still won't take your word for it.
TypeScript is answering a question you didn't ask yourself: when does that arrow function run? Sometimes the answer is "after the value changed", and then the compiler is right. Sometimes the answer is "after nothing can change it anymore", and for years the compiler flagged those cases too. TypeScript 5.4, released on March 6, 2024, fixed the second group. Not all of it, though, and the cases it left alone all have a recognizable shape.
Narrowing is a fact about a place in the code
Narrowing is control-flow analysis. When you write if (status), the checker records that from this line on, status is a string until an assignment in that stretch says otherwise. The narrowed type belongs to a location, not to the variable.
A callback breaks that. Its body is a separate function, and the code that calls it decides when it runs: right now (map), 100 ms from now (setTimeout), ten times, or never. Here is a program where the compiler's suspicion is correct:
function watch(load: () => string | undefined) { let status = load(); if (status) { setTimeout(() => { console.log(status.length); // error: 'status' is possibly 'undefined'. }, 100); } status = load(); } The timer fires after status = load() has run, and that second call can return undefined. The if checked a value that is gone by the time the callback reads the variable. The error is a real bug report.
Before 5.4, the rule was blunt. According to the pull request that changed it, narrowing from outside a closure was kept only for const variables and for parameters that are never assigned. Any let, and any parameter you touched even once, fell back to its declared type inside every closure.
Function calls get the opposite treatment. In the 2016 design discussion Trade-offs in Control Flow Analysis, the team weighed resetting narrowings after every call against keeping them. Anders Hejlsberg concluded that the "optimistic assumption that type guards are unaffected by intervening function calls is the best compromise". So TypeScript trusts that log() won't undo your check, but it does not trust a closure that runs at an unknown time.

The check happens now. The callback runs whenever its caller decides.
The false alarm 5.4 removed
Most of the time the old rule was wrong in the other direction. You normalize a parameter at the top of a function and then build closures that use it. This example comes straight from the 5.4 release notes:
function getUrls(url: string | URL, names: string[]) { if (typeof url === "string") { url = new URL(url); } return names.map(name => { url.searchParams.set("name", name); return url.toString(); }); } Before 5.4 this failed with Property 'searchParams' does not exist on type 'string | URL'. The arrow function is created after the assignment to url, and that assignment is the last one in the function. Nothing can change url once the closure exists. The old rule only saw "this parameter is assigned somewhere" and gave up.
The same applied to the common default-value idiom. The PR lists a parameter defaulted with ??= before a returned arrow function as a case that used to error and is now accepted:
function greeter(name: string | undefined) { name ??= "guest"; return () => `Hello, ${name.toUpperCase()}`; } What 5.4 actually checks
The new rule, in the release notes' words: "When parameters and let variables are used in non-hoisted functions, the type-checker will look for a last assignment point. If one is found, TypeScript can safely narrow from outside the containing function." The PR adds catch clause variables to that list.
The check is cheap on purpose. The PR diff adds an isPastLastAssignment helper that compares the position of the variable's last assignment with the position of the reference: one comparison of source positions per reference.
One edge follows from that design. An assignment inside an if/else counts as happening at the end of the whole statement. A closure created after the statement gets the narrowed type. A closure created inside one of the branches keeps the wider type, even when the assignment sits on the line above it. Hejlsberg notes in the PR that narrowing those branch closures "would require full control flow analysis which is significantly more complex and expensive than the single pass we do now."
Four cases that still reset
1. A let reassigned after the closure
That's the watch example from the top, and 5.4 still reports it, correctly. The fix is a const copy:
if (status) { const current = status; setTimeout(() => { console.log(current.length); }, 100); } This compiles, and it also changes what the program does: the callback logs the value you checked, not whatever status holds 100 ms later. That is usually what you meant in the first place.
2. Any assignment inside a nested function
If a closure anywhere in the function writes to the variable, narrowing is off in every closure. The release notes show it with two timers:
function printValueLater(value: string | undefined) { if (value === undefined) { value = "missing!"; } setTimeout(() => { value = value; }, 500); setTimeout(() => { console.log(value.toUpperCase()); // error: 'value' is possibly 'undefined'. }, 1000); } Even value = value is enough, because "there's no way to know for sure whether the function will be called later." The fix: don't assign captured variables from inside callbacks. Resolve the default into a const first (const label = value ?? "missing!";) and have the callbacks read label. If a callback really must write shared state, give it a holder whose property type is already non-optional, such as const state = { value: label }.
3. Function declarations
The 5.4 rule covers arrow functions, function expressions, class expressions and object methods. The PR excludes inner function and class declarations, because they are hoisted:
function greet(name: string | undefined) { name ??= "guest"; function shout() { return name.toUpperCase(); // error: 'name' is possibly 'undefined'. } return shout(); } A hoisted shout can be called from the first line of greet, before the ??= runs, so "created after the last assignment" has no meaning for it. Turn it into const shout = () => name.toUpperCase(); below the assignment and the error goes away.
4. Object properties
Everything above is about variables. A property path like order.coupon is not a variable, and narrowing it does not carry into a callback. Issue #52055 shows the shape with a nested property inside [].map:
type Order = { coupon?: string }; function tag(order: Order, skus: string[]) { if (order.coupon !== undefined) { return skus.map((s) => `${s}-${order.coupon.length}`); // error: 'order.coupon' is possibly 'undefined'. } return skus; } Any code holding a reference to order could set coupon back to undefined, and the checker can't track who that is. Two fixes. Destructure first (const { coupon } = order;) and check the const. Or narrow the object itself with a type guard and an early return:
function hasCoupon( o: Order, ): o is Order & { coupon: string } { return o.coupon !== undefined; } function tag(order: Order, skus: string[]) { if (!hasCoupon(order)) return skus; return skus.map((s) => `${s}-${order.coupon.length}`); } This works because the narrowing now lives on order, a parameter that is never assigned, and that case has been preserved in closures since before 5.4. The guard is a promise you make to the compiler, though. With map, which runs synchronously, the promise holds. With a timer or a promise callback, take the const copy instead.

Three fixes: snapshot the value, leave early, or narrow the thing the closure actually holds.
Picking the fix
When the compiler flags a variable inside a callback, ask when the callback runs.
If it runs later (timers, promise handlers, event listeners), copy the value into a const inside the check. You get snapshot semantics, which is almost always the behavior you wanted.
If the function has a guard at the top, use an early return. Every arrow function or function expression written below the guard sees the narrowed type: for a never-assigned parameter well before 5.4, and since 5.4 also after a ??= or other final assignment.
If the thing you checked is a property, destructure it into a const, or, for synchronous callbacks like map, narrow the object with a type guard.
Skip the non-null !. It compiles every one of these cases, including watch, where the compiler had found a real bug.
If this was useful
Narrowing is the part of TypeScript you use on every line without thinking about it, until a callback breaks it. TypeScript Essentials covers types, narrowing, modules and async for working developers, which is where questions like "why did my check stop counting" belong. If you want the type-level side after that, The TypeScript Type System is the next step.
- TypeScript Essentials — types, narrowing, modules, async, daily-driver tooling
- The TypeScript Type System — generics, mapped/conditional types, infer, template literals, branded types
- Kotlin and Java to TypeScript — JVM developers moving to TypeScript
- PHP to TypeScript — PHP 8+ developers moving to TypeScript
- TypeScript in Production — tsconfig, build tools, monorepos, library authoring, dual ESM/CJS, JSR
Books 1 and 2 are the core path. Book 3 or 4 swaps in if you think in Kotlin or PHP, and book 5 is for anyone shipping TypeScript at work. The whole set is at xgabriel.com/the-typescript-library.
All five books ship in ebook, paperback, and hardcover.


