Ch. 2 · TypeScript

TypeScript Null Narrowing Across Control Flow

How strictNullChecks narrows through returns, closures and filter calls, where it keeps a narrowing it shouldn't, and what to do about Map.get.

~6 min readadvancedupdated Oct 7, 2026

With strictNullChecks on (it’s part of strict), null and undefined stop being members of every type. A function that may not find a user returns User | undefined, and you have to deal with the undefined before reading .id. The compiler tracks those checks through your code using control flow analysis. Most of the time it’s invisible. The advanced interview questions are about its edges: what it forgets, and the one case where it remembers something it shouldn’t.

How a check narrows the rest of a function

Here’s a lookup with two levels of possibly-missing data:

type User = { id: string; manager: User | null; email?: string };
declare function findUser(id: string): User | undefined;

function managerEmail(id: string): string {
  const user = findUser(id);
  if (!user) return "unknown user";
  if (user.manager === null) return "no manager";
  return user.manager.email ?? "no email";
}
TypeScript

That compiles under strict. After if (!user) return, every path that reaches the next line has a defined user, so its type is User from there on. The compiler doesn’t need the code to be nested inside an if block; it follows the possible paths through returns, throws, break and continue. The same works with ===, typeof, instanceof, in, truthiness checks and optional chaining.

A few distinctions are worth having straight. !user treats every falsy value as missing, which is fine for objects but wrong for a number | undefined where 0 is valid. user === undefined checks one value exactly. user == null (loose equality) matches both null and undefined and is the one place loose equality is idiomatic. And ?? falls back only on null or undefined, while || also replaces "", 0 and false.

Closures and the reassignment rule

Narrowing is about a variable at a point in time. Callbacks run later, so the question is whether the narrowing still holds when they run:

function closures(id: string) {
  let user = findUser(id);
  if (!user) return;
  setTimeout(() => console.log(user.id)); // ok
}

function closuresReassigned(id: string) {
  let user = findUser(id);
  if (!user) return;
  setTimeout(() => console.log(user.id)); // error
  user = findUser("other");
}
TypeScript
null1.ts(20,32): error TS18048: 'user' is possibly 'undefined'.
Text

Since TypeScript 5.4, a narrowing carries into a closure when the variable is a const, or a let that is never assigned after the closure is created. In the first function nothing can change user before the timeout fires, so it stays User. In the second, the reassignment happens after the closure is created and before it runs, so user could be undefined again by the time the callback reads it. The compiler is right to complain. Before 5.4 both versions failed, which is why older code is full of const u = user; lines just before a callback.

The easy habit is to prefer const. A narrowed const is narrowed everywhere it’s visible.

The narrowing that outlives a function call

Now the unsound case, which is the best advanced question in this area. Narrow a property, then call a function:

type User = { id: string; manager: User | null };

function printManager(u: User, beforePrint: () => void) {
  if (u.manager !== null) {
    beforePrint();
    console.log(u.manager.id);
  }
}

const boss: User = { id: "boss", manager: null };
const ada: User = { id: "ada", manager: boss };
printManager(ada, () => { ada.manager = null; });
TypeScript

This compiles with no errors under strict. Running it throws:

TypeError: Cannot read properties of null (reading 'id')
Text

TypeScript narrowed u.manager to User and kept that narrowing across beforePrint(). It doesn’t invalidate property narrowings on function calls, even though the callback here sets manager back to null through another reference to the same object. The TypeScript team made this trade-off deliberately: if every call reset every property narrowing, ordinary code would need re-checks after every console.log. Instead, the checker is optimistic.

The defence is to copy the property into a local const before the call:

function printManagerSafe(u: User, beforePrint: () => void) {
  const manager = u.manager;
  if (manager !== null) {
    beforePrint();
    console.log(manager.id);
  }
}
TypeScript

Now the narrowed value is a local that nothing else can reassign. Whatever the callback does to u, manager still holds the object you checked. This matters most in code that calls out to user-supplied callbacks, event emitters or await, since any of them can run code that mutates shared state.

Filtering out missing values

Mapping IDs to users gives you an array of User | undefined, and you usually want just the users:

const users = ["a", "b"].map(findUser);            // (User | undefined)[]
const found = users.filter((u) => u !== undefined); // User[] since 5.5
const viaBoolean = users.filter(Boolean);
const vb: User[] = viaBoolean;
TypeScript
null1.ts(36,7): error TS2322: Type '(User | undefined)[]' is not assignable to type 'User[]'.
  Type 'User | undefined' is not assignable to type 'User'.
    Type 'undefined' is not assignable to type 'User'.
Text

TypeScript 5.5 started inferring type predicates from simple function bodies, so the arrow (u) => u !== undefined is treated as (u) => u is User and filter returns User[]. filter(Boolean) doesn’t get the same treatment, because Boolean is a constructor function whose declared type isn’t a predicate, so the result keeps undefined. Older code works around it with an explicit predicate, (u): u is User => u !== undefined, which still works and is clearer about intent.

Non-null assertions and assertion functions

The ! operator removes null and undefined from a type without any check: findUser(id)!.id compiles and crashes if the user is missing. It’s acceptable where an invariant really holds and the compiler can’t see it, and a liability everywhere else, because it gives no message when it’s wrong.

Map.has followed by Map.get is the classic place people reach for it:

if (map.has(k)) {
  return map.get(k).id;
}
TypeScript
null3.ts(9,12): error TS2532: Object is possibly 'undefined'.
Text

has and get are separate calls, and the type of get is always V | undefined, so the check doesn’t narrow anything. Instead of map.get(k)!, call get once and test the result: const user = map.get(k); if (user) return user.id;. It’s one lookup instead of two, and it’s checked.

When a missing value is a genuine error, an assertion function gives you a check with a message and narrowing afterwards:

function assertDefined<T>(value: T, msg: string): asserts value is NonNullable<T> {
  if (value === undefined || value === null) throw new Error(msg);
}

const user = findUser(id);
assertDefined(user, `user ${id} not found`);
return user.id; // user is User here
TypeScript

The asserts value is ... return type tells the compiler that if the function returns at all, the condition holds. Unlike !, it fails loudly at the right place with a useful message.

Interview practice

This code compiles under strict, yet production logs show Cannot read properties of null (reading 'total'):

async function checkout(session: Session) {
  if (session.cart !== null) {
    await reserveStock(session.cart);
    charge(session.cart.total);
  }
}
TypeScript

Why didn’t TypeScript catch it, and how would you fix it?

Answer

TypeScript narrowed session.cart to non-null in the if and kept that narrowing across the await. It doesn’t invalidate property narrowings on function calls or awaits, by design, because doing so would force re-checks everywhere. But while checkout was suspended at await, other code ran. A logout handler or a “clear cart” request on the same session object most likely set session.cart = null, so the next line read .total from null.

The fix is to capture the value once: const cart = session.cart; if (cart !== null) { await reserveStock(cart); charge(cart.total); }. Now the code charges the cart it checked, whatever happens to the session in between. I’d also ask whether charging a cart that the user has since cleared is the right behaviour. If not, re-read and re-check session.cart after the await, and treat a change as a reason to abort, which turns a type question into the business decision it really is.

Further reading

More in TypeScript

esc