skip to content

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%

answer

  1. the diagnostic is a symptom, not the bug
  2. shorten the window between check and use
  3. immutable locals at the boundary
  4. replaced union beats mutated optional fields
  5. snapshot and live-read mean different things

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.

solid answer

~50 s

The recurring error is a symptom, not the problem: values that can change are being checked early and used late. My conventions would be, roughly in order of leverage — narrow at the boundary and bind the result to a `const`, so every later guard sticks; pass narrowed values into callbacks as parameters rather than closing over mutable state; model lifecycle as a discriminated union replaced wholesale instead of a bag of optional fields mutated in place; and keep awaits out of the middle of a narrowed block. Assertions stay allowed only with a comment stating the invariant and why it holds. The costs are real: more intermediate locals and slightly longer functions, a refactor of existing state objects, and a semantic shift to snapshot reads that the team must choose deliberately — sometimes you genuinely want the live value, and then the right answer is a re-check, not a const.

go deeper

for a junior

Take away one habit: pull the value you checked into a const at the top of the function and use that. It removes most of these errors without any assertion.

for a middle

Be able to explain why the convention works — an immutable binding cannot be invalidated, so its narrowed type survives into callbacks — and to point out that a snapshot and a late read behave differently.

for a senior

Show you can diagnose the pattern behind a pile of similar diagnostics and fix the state that causes them, not the call sites. Be specific about the await case, which no tool will flag for you, and about when a re-check beats a snapshot.

for a principal

Own the rollout and the tradeoff: which modules to remodel, what the convention costs in verbosity and refactor churn, which parts are enforceable and which rely on review, and why the target is meaningful diagnostics rather than zero of them.

## Read the error as a design signal When the same diagnostic appears hundreds of times, the interesting question is not how to satisfy the compiler but what the compiler is repeatedly noticing. Here it is noticing one shape: a value that can change is checked at one moment and consumed at another — inside a deferred callback, or after an await, or several calls later. Each individual suppression is cheap; the aggregate is a codebase where the null checks no longer mean anything, because a reader cannot tell which ones were reasoned about. So the goal of the convention is not fewer diagnostics. It is fewer places where a checked value can change between check and use. ## The conventions, by leverage **Narrow once, at the boundary, into a const.** At the top of a function, destructure or copy the inputs you will use into immutable locals, then guard those. A `const` binding is never invalidated, so its narrowed type is available everywhere afterwards, including inside closures. This single habit removes most of the callback-related errors and costs one line. **Pass narrowed values as arguments, not as captured state.** A callback that takes `(socket: WebSocket)` as a parameter cannot be wrong about it; a callback that closes over a nullable module-level `let` can be. Where the API allows it, hand the value in rather than reaching out for it. **Model state as a replaced discriminated union.** Optional fields mutated in place — `pending?: Task`, `socket?: Socket`, `user?: User` — are the raw material for this bug. A union like `{ status: "idle" } | { status: "running"; task: Task }` that is replaced as a whole gives you two things: a check that narrows the whole object at once, and a value you can snapshot in one const. It also makes impossible combinations unrepresentable, which usually removes other bugs on the way past. The relevant modelling detail here is that the *replacement* discipline is what preserves narrowing; mutating a field of the same object does not. **Keep awaits out of narrowed blocks.** Do the async work, then check, then act. When you cannot, snapshot before the await or re-check after it, and say in review which one you meant. The compiler will not flag this, so it is a human review rule. **Bound the escape hatch.** Assertions and casts are not banned — they are made expensive: a comment naming the invariant and why it holds, and no assertion on a value that any other module can write. Anything that fails those tests gets refactored instead. ## What it costs, honestly - **Verbosity.** More intermediate locals; short functions grow a preamble. Reviewers who value density will push back, and they are not wrong that a copy is noise when the value is already immutable. - **A semantic decision, forced.** A snapshot const reads the value at check time; the old code re-read it late. Those differ, and mechanically applying the convention can freeze a value that was supposed to be live. The convention must include "if you need the current value, re-check instead" or it will introduce staleness bugs while fixing crash bugs. - **Refactor cost on existing state.** Converting optional-field state objects into discriminated unions touches every reader. It is worth doing where lifecycle bugs actually occur; doing it everywhere at once is a large diff with little payoff in stable modules. - **No enforcement for the await case.** There is no lint rule that will find a narrowed property read after an await, so that part relies on review discipline, which decays. Be honest that it is weaker than the others. ## How I would roll it out Start by measuring: count the suppressions and group them by module. Usually a small number of state-holding modules produce most of them, and those are the ones worth remodelling. Fix those, write the convention down with the two escape valves (snapshot vs re-check) spelled out, and require new code to follow it — rather than a codebase-wide sweep that produces an unreviewable diff and no change in habit. The outcome to aim for is not zero diagnostics. It is that when one appears, it means something, and the person who sees it reasons about the lifetime of the value rather than reaching for the shortest way to make it disappear. ## The tradeoff to be able to state Every one of these conventions trades some local convenience for a narrower window in which state can change. That trade is clearly right for long-lived mutable state read from deferred code, and clearly wrong as a blanket rule applied to short pure functions. Being able to say where the line sits — and to accept the verbosity where it buys something — is the judgment the question is asking for.

  • How would you decide which modules to remodel first rather than applying the convention everywhere?
    Count suppressions and crash reports per module. State-holding modules — connections, in-flight task registries, session objects — usually produce most of both, and they are where remodelling pays. Stable, mostly-pure modules get the convention for new code only; a blanket sweep there is a large diff that changes no outcomes.
  • Can any of this be enforced automatically, or is it all review discipline?
    Partly. The compiler already enforces the closure case for you — that is why the errors exist. Prefer-const style rules push toward immutable locals. But a narrowed property read after an await is invisible to the compiler and to ordinary lint rules, so that piece stays a review convention, and I would say so rather than pretend it is enforced.
  • When is the mechanical fix — copy into a const — the wrong answer?
    Whenever the code is supposed to observe the current value. Snapshotting a handler or a connection freezes what existed at check time, so a swap or a reconnect is ignored. There the honest fix is to read late and guard again at the point of use, accepting that the value may now be absent and handling that case.
  • How do you keep the assertion escape hatch from quietly becoming the default again?
    Make it cost something visible: a required comment naming the invariant, and a rule that no assertion may stand on a value another module can write. Then review the population periodically — if the count is growing, the convention is not working, and the right response is to fix the state model rather than to tighten the rule.

saying these in an interview costs you the question

  • Proposes banning assertions outright with no replacement pattern
  • Treats the goal as zero compiler diagnostics
  • Applies the const snapshot mechanically where a live read was intended
  • Ignores that awaits reopen the window with no compiler help
  • Plans a codebase-wide sweep instead of targeting the state-holding modules

context