skip to content

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%

answer

  1. only assignments reset a narrowed reference
  2. calls are assumed not to mutate
  3. await lets the whole program run
  4. usability trade over soundness
  5. snapshot into a const, or re-check

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.

solid answer

~50 s

The code compiles, and it can still throw at runtime. Control-flow analysis invalidates a narrowed reference when it sees an assignment to it in the same function body; an ordinary call, and an `await`, are not assignments, so the fact that `state.pending` is present survives across both. `flush()` may well have set it to undefined, and any other task can run during the await — so the compiler's answer here is unsound by design. TypeScript accepts this because invalidating every narrowing at every call would make normal code unusable. The safe patterns are explicit: read the value into a const before the await and operate on that (`const pending = state.pending; if (pending) { await flush(); pending.cancel(); }`), or re-check the property after the await if you want the current value. Treat any await inside a narrowed block as a point where you re-establish the invariant yourself.

code

typescript · 16 lines
typescript
interface State { pending?: { cancel(): void } }

async function stop(state: State, flush: () => Promise<void>): Promise<void> {
  const pending = state.pending;
  if (pending) {
    await flush();
    pending.cancel();
  }
}

async function stopLive(state: State, flush: () => Promise<void>): Promise<void> {
  if (state.pending) {
    await flush();
    state.pending?.cancel();
  }
}

go deeper

for a junior

Take away the concrete rule: after an await, do not assume a property you checked earlier is still set. Re-check it, or copy it into a const before the await and use that.

for a middle

Explain what invalidates narrowing — assignments visible in the same body, and branch merges — and therefore why a call or an await leaves the narrowed type in place. Show the const-snapshot and re-check fixes and say how they differ.

for a senior

Name it as a deliberate unsoundness and justify the trade: resetting narrowing at every call would flood real code with re-checks. Then demonstrate production judgment about the await window, where other tasks run, and flag such reads in review since the compiler never will.

for a principal

Own the structural fix: optional fields mutated in place invite this bug across a whole codebase. Argue for lifecycle state modelled as a replaced discriminated union and for keeping awaits out of narrowed blocks, and be honest about the migration cost of that convention.

## What actually invalidates narrowing Control-flow analysis tracks a narrowed reference until something it can see disturbs it. In practice that means an assignment, in the same function body, to that reference or to a prefix of it — `state.pending = ...` or `state = ...`. It also means a branch merge, where the type becomes the union of the incoming branches. That is the whole list the checker consults for a property path. What is *not* on the list: calling a function, awaiting a promise, yielding from a generator, or throwing and catching. None of those are assignments the checker can attribute to the reference, so a narrowed property stays narrowed straight through them. ```ts interface State { pending?: { cancel(): void } } async function stop(state: State, flush: () => Promise<void>) { if (state.pending) { await flush(); // may set state.pending = undefined state.pending.cancel(); // compiles — and may throw at runtime } } ``` ## Why this is deliberate The alternative would be to reset every narrowing at every call, since almost any function *could* mutate an object reachable from it. Under that rule, code like `if (user.profile) { log(user); user.profile.name }` would fail, and the fix would be to re-check after every call or to assert everywhere. The result would be worse code and more assertions, which is a net loss for safety. So the compiler makes a usability trade: it assumes calls do not mutate the objects you narrowed. This is one of TypeScript's well-known intentional unsoundnesses, in the same family as assertions and bivariant method parameters — places where the type system knowingly reports "fine" for something that can fail. Knowing where these live is exactly what a senior interview is probing. ## Why await makes it worse A plain call at least runs to completion before your next statement. An `await` suspends the function and lets the entire rest of the program run: other handlers, timers, socket events, a second invocation of this same handler. The window in which someone can clear `state.pending` is not a few instructions, it is unbounded. Anything you narrowed before an await, and any other invariant you checked before it, is a claim about the past by the time execution resumes. Note the contrast with the closure case. Inside a nested function the checker *does* drop the narrowing, because it cannot order the body at all. Across an await it keeps the narrowing, because the statements are still in one ordered body — even though the real-world risk is comparable. That asymmetry is worth being able to state plainly. ## Writing it safely There are two honest options, and they mean different things. Snapshot before the await: ```ts const pending = state.pending; if (pending) { await flush(); pending.cancel(); // the object we checked, whatever the state says now } ``` The const holds the object that existed at check time. `cancel()` is guaranteed to run on a real object. Whether cancelling a task that was already flushed is correct is a domain question you must answer — but there is no crash and no lie in the types. Re-read after the await: ```ts if (state.pending) { await flush(); state.pending?.cancel(); // current value, guarded again } ``` Here you accept that the field may have changed and handle the case. Use this when the field is the source of truth and a cleared field means "nothing to cancel". ## Structural defences Beyond the local fix, the durable answers reduce how often mutable narrowed state is read late: - Destructure inputs into locals at the top of the function, so guards apply to immutable bindings. - Model lifecycle state as a discriminated union that is replaced wholesale (`{ status: "idle" } | { status: "running"; task: Task }`) rather than as a bag of optional fields flipped in place; a replaced object cannot be half-updated under you. - Keep awaits out of the middle of narrowed blocks — do the async work first, then check, then act. - In review, treat "narrowed property read after an await" as a smell worth a second look, since the compiler will never flag it. ## The interview answer in one line The compiler is silent because calls and awaits are not assignments; the silence is a designed trade-over-soundness; and the code is only safe if you snapshot the value or re-check it yourself.

  • If awaits do not reset narrowing, why does the checker drop narrowing inside a callback passed to that same async function?
    Because the two situations differ in what the checker can order. Statements around an await are still one ordered body, so it keeps the facts. A nested function body has no position in any order — it may run at any time or many times — so the checker refuses to carry facts in at all.
  • Would it be better if TypeScript invalidated narrowing at every function call?
    It would be sounder and far less usable. Almost any call could mutate a reachable object, so virtually every narrowing would die at the first log or helper call, and real code would fill with re-checks and assertions. The current rule trades a known unsound corner for ergonomics that keep the rest of the checking honest.
  • Does declaring the state object with readonly properties close this hole?
    Not really. `readonly` prevents writes through that type but does not stop another alias with a mutable type from writing, and nothing enforces it at runtime. It documents intent and catches direct mistakes; it does not make the post-await access safe. Snapshotting or re-checking still does.

saying these in an interview costs you the question

  • Assumes any function call resets narrowing of a property
  • Says the code cannot crash because it type-checks
  • Treats await as a barrier the checker knows about
  • Claims readonly or const on the object prevents the mutation
  • Concludes TypeScript is simply broken rather than trading soundness for usability

context