skip to content

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%

answer

  1. properties belong to a mutable object
  2. the callback may run much later
  3. narrowing tracks references, not just names
  4. hoist into a const before the guard
  5. or read late with an optional call

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.

solid answer

~40 s

The guard narrows the *reference* `options.onSave` at that point in the flow, but a nested function has no place in that flow — the checker cannot know when `setTimeout` will invoke it, and in the meantime anything holding `options` could do `options.onSave = undefined`. So inside the callback body the property reverts to its declared type `(() => void) | undefined`, which is not callable. The one-line fix is to hoist it: `const onSave = options.onSave;` then `if (onSave) setTimeout(() => onSave(), 0);`. `onSave` is a const, so nothing can invalidate the narrowing and the callback is type-safe. If you would rather read the property at invocation time, `setTimeout(() => options.onSave?.(), 0)` also compiles, and it deliberately calls whatever is there when the timer fires.

code

typescript · 10 lines
typescript
function schedule(options: { onSave?: () => void }): void {
  const onSave = options.onSave;
  if (onSave) {
    setTimeout(() => onSave(), 0);
  }
}

function scheduleLive(options: { onSave?: () => void }): void {
  setTimeout(() => options.onSave?.(), 0);
}

go deeper

for a junior

Know the reflex: read the optional property into a local const, check the const, and use the const inside the callback. Say plainly that a property on a mutable object can change before the callback runs.

for a middle

Explain that the checker narrows the reference options.onSave only within the enclosing flow, and that a nested body is outside that order, so the declared optional type comes back. Show both fixes and name which one reads the property late.

for a senior

Frame it as a real race rather than a compiler quirk: the handler can be cleared between scheduling and firing. Be ready to justify snapshot versus live-read semantics for the callback you are writing and to reject assertions that hide the difference.

for a principal

Push on API shape: options bags full of mutable optional callbacks generate this friction everywhere. Argue for validating and freezing inputs at the boundary, or for required handlers with explicit no-op defaults, and weigh that against caller convenience.

## What the checker narrowed, and where TypeScript narrows *references*, not just variables. `options.onSave` is a reference the checker can track, so `if (options.onSave)` records that on the true branch that reference has type `() => void` rather than `(() => void) | undefined`. That fact is attached to a point in the control flow of the enclosing function body. A callback is not a point in that flow. `setTimeout(() => ..., 0)` builds a function value and hands it to the host; the body may run in a millisecond, or never. So the checker asks whether it can still trust the fact when the body eventually runs — and for a property of an object parameter the answer is no. ## Why properties are treated as changeable `options` is an object the caller owns and may still hold a reference to. Nothing stops this: ```ts const opts = { onSave: () => console.log("saved") }; schedule(opts); opts.onSave = undefined as any; // now the queued callback would crash ``` The property is mutable and reachable from outside the function, so the checker discards the narrowing inside any nested function body and falls back to the declared type. Calling a value of type `(() => void) | undefined` is the error you see: "Cannot invoke an object which is possibly 'undefined'". This is the same rule that drops narrowing of a reassignable `let` inside a closure, applied to a property path. The common thread is not the syntax of the guard — it is whether the thing you narrowed can be changed before the deferred body runs. ## The fix, and what it means ```ts function schedule(options: { onSave?: () => void }): void { const onSave = options.onSave; // read once, into an immutable binding if (onSave) { setTimeout(() => onSave(), 0); // ok: onSave is () => void } } ``` The const holds the function value that was present at check time. A `const` cannot be reassigned, and the checker knows it, so the narrowed type is valid at any moment the callback might run. This is why destructuring at the top of a function — `const { onSave } = options;` — is such a common style in TypeScript code: it converts mutable property paths into immutable locals up front and makes every later guard stick. Be clear about what changed semantically. The const version calls the handler that was installed when `schedule` ran. If the caller swaps `options.onSave` in the meantime, the const version still calls the old one. That is usually what you want for a callback you have already committed to, but it is a decision, not a formality. ## The live-read alternative If you want whatever handler is present when the timer fires, do not hoist — read the property inside the callback and guard there: ```ts setTimeout(() => options.onSave?.(), 0); ``` Or equivalently with an explicit check inside the body, since control-flow analysis restarts in every function body: ```ts setTimeout(() => { if (options.onSave) options.onSave(); }, 0); ``` Both compile, and both read the property late. The optional call `?.()` is the compact form: it invokes only when the value is neither null nor undefined. ## What not to do Silencing the diagnostic with a non-null assertion removes the only warning you get about a genuine race — the handler being cleared between scheduling and firing. The compiler is not being pedantic here; it is describing something that can actually happen with a mutable options object. Choose the snapshot or choose the live read, and let the type system keep checking either one. ## The shape to remember The error appears any time a checked *optional property* is used inside a deferred body: event handlers, timers, promise callbacks, array-iteration callbacks, and continuations of every kind. The reflex is the same in all of them — pull the value into a local const before the guard, or guard again inside the body.

  • Does making the property `readonly` in the type let the narrowing survive into the callback?
    No. `readonly` only stops assignments through that particular type — the same object can be reached through another type or another alias that permits writing, and nothing about it exists at runtime. The checker still discards property narrowing inside a nested function, so hoist to a const.
  • What does `options.onSave?.()` do differently from the hoisted const version?
    It reads the property at the moment the callback runs and calls it only if it is not null or undefined, so a handler swapped in after scheduling is the one invoked. The const version snapshots the handler at check time. Pick whichever matches the behaviour you want.

saying these in an interview costs you the question

  • Says the check simply does not work on properties at all
  • Claims optional properties can never be narrowed
  • Adds a non-null assertion and calls it fixed
  • Thinks marking the property readonly restores the narrowing
  • Believes the problem is specific to setTimeout being asynchronous

context