A TypeScript module has `let current: User | null = null;` and an exported `reset()` that sets it to null. Another function writes `if (current !== null) { reset(); console.log(current.name); }` — this compiles but throws at runtime. Why does the compiler allow it, and how would you make the code safe?
answer
- the checker sees the call, not the callee
- invalidated only by assignments it can see
- soundness traded for usable narrowing
- copy it where nothing else can write
basics
~20 sControl-flow analysis only invalidates a narrowing at assignments it can see on the path being analysed. A call to reset() is opaque to it, so the narrowing survives the call even though the value no longer holds. Copy the checked value into a local const to make the read safe.
solid answer
~50 sNarrowing is recomputed from the flow graph of the function being checked, and that graph contains the call to `reset()` but not its body. The compiler does not attempt to work out which mutable bindings an arbitrary call might reassign — doing so soundly would mean whole-program analysis and would invalidate narrowing after almost every call, making the feature useless. So the narrowing established by `if (current !== null)` is still in force at `current.name`, and the check compiles while the runtime value is `null`. This is a deliberate, documented unsoundness. The fix is to stop reading through the mutable binding: `const user = current; if (user === null) return; reset(); user.name;` — a `const` local cannot be reassigned by anyone, so the narrowing the compiler proved is the narrowing that still holds. Structurally, the deeper fix is not to keep mutable module-level state that other code can reset out from under a reader.
code
typescript · 18 linesinterface User { name: string }
let current: User | null = null;
function reset(): void { current = null; }
function unsafe(): void {
if (current !== null) {
reset();
console.log(current.name); // compiles; throws at runtime
}
}
function safe(): void {
const user = current; // snapshot the binding
if (user === null) return;
reset();
console.log(user.name); // sound: user cannot be reassigned
}go deeper
Take away one rule: a check narrows the variable for the compiler, not forever, and other code can still change a module-level variable between the check and the use.
Explain that analysis is confined to the function being checked, so a call is opaque and cannot invalidate a narrowing, and show the snapshot-into-a-const rewrite.
Diagnose it from the shape — mutable binding outside the function, read after a call — argue why the unsoundness is a deliberate tradeoff, and pick the fix that removes the hazard rather than the warning.
Own the structural call: mutable module-level state is what makes the hole reachable, so decide whether the codebase permits it at all, and encode the answer in review guidance rather than relying on individuals spotting the pattern.
## What the compiler actually checks Control-flow analysis is **intraprocedural**: when checking a function, TypeScript walks that function's own flow graph. A call node in that graph is just a node — the compiler knows the callee's *signature*, and nothing about what its body does to variables outside it. ```ts // module scope export let current: User | null = null; export function reset(): void { current = null; } export function greet(): void { if (current !== null) { reset(); console.log(current.name); // compiles — narrowing survived the call } } ``` The `if` puts `current` at `User` on the true edge. Between the check and the read there is no assignment *in this function*, so the narrowing is still in force at `current.name`. At runtime `reset()` has already set the binding to `null`, and the property read throws. ## Why the compiler is built this way The alternative is to assume any call may reassign any reachable mutable binding. That is the sound choice, and it is unusable: every narrowing would be discarded at the first function call, including calls that obviously cannot touch the variable. Real code would be forced back into re-checking after every statement, or into assertions everywhere — which is strictly worse for safety, because assertions are unchecked. So TypeScript takes the pragmatic position: **narrowing is invalidated by assignments the analysis can see**, not by assignments it must imagine. That includes assignments in the current function, and re-narrowing on reassignment. It excludes side effects hidden behind calls. This is one of a small family of deliberate soundness holes the language documents and expects engineers to know — the compiler is a very good assistant, not a proof system. ## Recognising the shape in review The pattern has three ingredients, and all three must be present: 1. a **mutable** binding (`let`, or a mutable property) that 2. lives **outside** the function doing the check — module scope, a class field, a captured variable — and 3. is read **after** a call that can reach it. A `let` that is local to the function and never captured cannot be hit: nothing else can reassign it, so the analysis sees every assignment there is. That is why the bug clusters around module singletons, caches, and "current user / current request / current connection" style state. ## The fixes, weakest to strongest **Snapshot into a `const`.** The minimal change, and it is genuinely sound: ```ts export function greet(): void { const user = current; // read once if (user === null) return; reset(); console.log(user.name); // safe — user cannot be reassigned } ``` The narrowing now applies to a binding that no other code can touch. Note the semantic change you are accepting: `user` is the value as of the read, so if `reset()` were meant to affect what this function sees, it no longer does. That is usually what you wanted, but say it out loud. **Pass the value instead of reading the global.** Make the dependency a parameter (`greet(user: User)`), so the caller resolves the state once and the function has no ambient binding to go stale. **Remove the mutable module binding.** Export a getter over private state, or hold the state in an object owned by a caller. A module-level `let` that anything can reassign is a hazard independent of TypeScript: it makes every reader's assumptions depend on execution order. ## Related traps worth naming - **Object properties behave the same.** `if (obj.value !== null) { doSomething(); obj.value.length; }` compiles for the same reason — the compiler does not model what `doSomething` may write to `obj`. - **A non-null assertion is not a fix.** Writing `current!.name` silences the same code with no runtime check at all; it converts a compiler bug report you never got into an assumption you documented as safe. - **`readonly` does not help here.** It constrains writes through that reference at compile time; it does not stop `reset()` from writing through its own. ## What to say in an interview Name the mechanism (intraprocedural flow analysis, narrowing invalidated only by visible assignments), state that it is a chosen unsoundness with a stated rationale rather than a bug, give the snapshot-into-`const` fix, and then make the design point: the type system is telling you something about the code even when it says nothing — mutable ambient state is exactly the shape whose correctness a checker cannot underwrite.
- Would the same problem occur with a local let that is never captured?No. If the variable is local to the function and no nested function assigns to it, every assignment is inside the flow graph being analysed, so the narrowing is invalidated exactly where it should be. The hazard needs a binding something outside this function can write — module scope, a class field, or a captured variable.
- Does adding a non-null assertion at the read site address the issue?No, it hides it. `current!.name` produces the same emitted code and the same runtime throw, while removing any chance the compiler ever warns you. An assertion is a claim you are making to the checker, so using one here converts a real gap into a documented false assumption. The snapshot into a const is the change that actually removes the hazard.
- Why not make TypeScript invalidate narrowing after every function call?It would be sound and unusable. Almost every narrowing would die at the next call, so ordinary code would need re-checks or assertions everywhere — and assertions are unchecked, so the net effect would be less safety, not more. The language accepts this hole explicitly and expects engineers to recognise the mutable-ambient-state shape that triggers it.
saying these in an interview costs you the question
- Claims the compiler tracks side effects of called functions
- Says the code is fine because the check passed
- Reaches for a non-null assertion as the fix
- Thinks readonly on the variable would prevent it
- Assumes any narrowing is a runtime guarantee