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?
answer
- the checker cannot schedule your callback
- deferred body, unknown execution time
- reassignable binding falls back to declared type
- a const cannot be invalidated
- snapshot at check time, or re-check inside
basics
~20 sControl-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 sNarrowing 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
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.
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.
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.
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