skip to content

Narrowing Lost Across Closures

Narrowing is discarded inside function bodies whose execution order the checker cannot prove, so a variable you just null-checked is nullable again in the callback. Interviewers love this one because the standard fix — copy to a const — reveals whether you understand the reason.

part ofTypeScriptoverview, primer and where to startread it →
on this pageshow

questions

4

In TypeScript, a module keeps `let socket: WebSocket | null = null` and a connect() function that reassigns it. Inside send(), the line `if (socket !== null) { queueMicrotask(() => socket.send(data)); }` is rejected because socket is possibly null, yet writing `socket.send(data)` directly after the same check is accepted. Why is the narrowing discarded inside the arrow function, and what is the standard fix?

level: middleimportance: must knowfreq 68%

answer

  1. the checker cannot schedule your callback
  2. deferred body, unknown execution time
  3. reassignable binding falls back to declared type
  4. a const cannot be invalidated
  5. snapshot at check time, or re-check inside

basics

~20 s

Control-flow analysis stops at a function boundary for a binding that is reassignable: the checker cannot prove when the callback runs, so inside it the variable reverts to its declared type. Copy the narrowed value into a const and close over that.

solid answer

~50 s

Narrowing is a fact about a point in the control flow, and the compiler can only track control flow inside one function body. When you create a nested function, the compiler has no idea when it will be invoked — `queueMicrotask` may run it after `connect()` has set `socket` back to something else — so for a binding that is assigned anywhere in the program it throws the narrowing away and uses the declared type `WebSocket | null` inside the body. The straight-line call right after the check is fine because there the checker can see the flow and no assignment intervenes. The fix is to give the closure something that cannot change: `const s = socket; if (s !== null) queueMicrotask(() => s.send(data));`. A `const` can never be reassigned, so the narrowed type survives into the callback. Since TypeScript 5.4 a `let` also keeps its narrowing when every assignment to it precedes the function's creation — here it does not, because `connect()` assigns it.

go deeper

for a junior

Recognise the error message and know the standard move: copy the value into a const, check the const, and use the const inside the callback. Being able to name the fix is enough at this level.

for a middle

Explain the mechanism: control-flow analysis is per function body and cannot know when a nested function runs, so a reassignable binding reverts to its declared type inside it. Contrast that with the straight-line call the checker accepts.

for a senior

Show that the const capture is a semantic decision, not a compiler workaround — it snapshots the value at check time. Be ready to say when you want a live read plus an inner re-check instead, and why an assertion here hides a real reconnect or teardown race.

for a principal

Own the design question: long-lived mutable module state is what makes this class of error frequent. Argue for narrowing at boundaries into immutable locals, or for modelling connection state as a discriminated union, and weigh that against the churn of refactoring existing modules.

## What narrowing actually is TypeScript's type checker runs a control-flow analysis over each function body. It walks the statements in order and, at every reference to a variable, computes the type that variable must have *at that point* given the checks and assignments it has passed through. `socket` is declared `WebSocket | null`; after `if (socket !== null)` the checker records that on the true branch the reference has type `WebSocket`. All of this is compile-time bookkeeping — nothing about it exists in the emitted JavaScript. ## Why a function boundary ends the analysis The analysis is *ordered*. Statements in a body execute one after another, so the checker can say "by the time we reach line 7, the check on line 5 has run and nothing has reassigned the variable". A nested function has no position in that order. It is a value; it can be stored, passed, invoked once, invoked a thousand times, or invoked next week. `queueMicrotask(() => socket.send(data))` schedules the body for later, and between the check and the invocation the module's own `connect()` — or a reconnect handler, or a teardown that sets `socket = null` — may have run. So the rule is: when a reference to an outer variable appears inside a nested function, the checker asks whether the binding could ever change. If it could, it abandons the outer flow facts and uses the **declared** type, here `WebSocket | null`. Calling `.send` on that is an error. The mechanics of how the closure captures the binding at runtime are ordinary JavaScript scope rules; what matters here is only what the checker is willing to assume about *when* the body runs. ```ts let socket: WebSocket | null = null; function connect(url: string) { socket = new WebSocket(url); } function send(data: string) { if (socket !== null) { socket.send(data); // ok: same flow, no assignment between queueMicrotask(() => socket.send(data)); // error: 'socket' is possibly 'null' } } ``` Note the asymmetry in the first line: the direct call is accepted even though `connect()` could in principle be re-entered by another task. That is a deliberate, documented unsoundness — the checker trusts straight-line flow — while the deferred body is where it refuses to guess. ## What counts as "cannot change" - A `const` binding can never be reassigned, so its narrowing is always available inside closures. - A parameter or a `let` that is never assigned anywhere is treated the same way: no assignment exists, so nothing can invalidate the fact. - A `let` that *is* assigned somewhere is the failing case, and it fails even when the assignment lives in an entirely different function — the checker does not attempt to prove the two can never interleave. Since TypeScript 5.4 the rule is more generous: if every assignment to the variable appears **before** the function expression is created, the narrowing at that point is preserved inside the body. That covers the common shape where you normalise a parameter and then use it in a callback: ```ts function render(url: string | URL, names: string[]) { if (typeof url === "string") url = new URL(url); // last assignment return names.map(n => url.href + n); // 5.4+: url is URL here } ``` The `socket` case is not covered, because `connect()` assigns after — and TypeScript makes no attempt at inter-procedural ordering. ## The fix Capture the narrowed value in a new `const`, then narrow and use that: ```ts function send(data: string) { const s = socket; if (s !== null) { queueMicrotask(() => s.send(data)); // ok: s is WebSocket, and const } } ``` This is not a trick to silence the compiler — it changes the meaning of the code, and in the safe direction. The closure now holds the socket that existed at check time, so a later reconnect cannot make the callback act on a different object or on `null`. If you genuinely want the *current* socket at invocation time, then the compiler is right to object, and the honest fix is to re-check inside the callback body: control-flow analysis restarts within that body, so `() => { if (socket !== null) socket.send(data); }` type-checks and is correct at runtime. Choosing between the two is the real content of the question: snapshot semantics versus live-read semantics. Silencing the error with an assertion picks neither and removes the compiler's ability to warn you when the invariant breaks.

  • If the callback runs immediately rather than later — say inside an array `forEach` — does that change anything?
    No. The checker does not special-case eagerly-invoked callbacks passed to library functions; it has no way to know `forEach` calls the function synchronously and exactly once. The binding is still reassignable, so the narrowing is still dropped. The same const capture fixes it.
  • Why does re-checking inside the callback body work, when the outer check did not carry in?
    Control-flow analysis runs independently over every function body. Inside the callback, the check and the use sit in one ordered flow with no assignment between them, so the narrowing applies there — exactly as it does in any other function. You are simply asking the question at the moment the answer matters.
  • Does the const capture change runtime behaviour, or is it purely a type-level trick?
    It changes behaviour. The closure now captures the value read at check time rather than re-reading the mutable binding when it runs. If the variable is reassigned in between, the two versions do different things — so pick deliberately: snapshot with a const, or read live and re-check inside.

saying these in an interview costs you the question

  • Claims the arrow function cannot see the outer variable at all
  • Says types are erased, so narrowing never works in callbacks
  • Reaches straight for a non-null assertion without explaining the cause
  • Believes repeating the check just before the callback restores narrowing
  • Assumes narrowing is lost only for async callbacks, not synchronous ones

context

open as a page

In TypeScript, a function takes `options: { onSave?: () => void }` and writes `if (options.onSave) { setTimeout(() => options.onSave(), 0); }`. The compiler reports that `options.onSave` is possibly undefined inside the callback even though the check is on the line above. Why does the property check not carry into the callback, and what one-line change fixes it without an assertion?

level: juniorimportance: should knowfreq 52%

basics

~20 s

A property lives on a mutable object, so the checker will not assume it still holds its checked value whenever the callback later runs. Read it into a local const first, check that const, and call the const inside the callback.

open as a page

In TypeScript, a handler writes `if (state.pending) { await flush(); state.pending.cancel(); }`, where `pending` is an optional property and `flush()` may clear it. Does the compiler complain about the access after the await, is its answer sound, and how would you write this safely?

level: seniorimportance: should knowfreq 38%

basics

~20 s

No complaint: the checker invalidates narrowing only on assignments it can see in the same body, so calls and awaits leave it intact. That is deliberately unsound — snapshot the value into a const before awaiting, or re-check after it.

open as a page

A large TypeScript codebase keeps producing 'possibly null' and 'possibly undefined' errors when checked values are used inside callbacks and after awaits, and the team's habit is to silence each one at the call site. What conventions would you set so narrowing survives by construction, and what do those conventions cost?

level: principalimportance: nice to knowfreq 24%

basics

~20 s

Treat the errors as a signal that mutable state is read late. Narrow once at the boundary into immutable locals, pass narrowed values into callbacks as arguments, and model state as replaced discriminated unions rather than optional fields flipped in place.

open as a page