skip to content

TypeScript's narrowing through const-aliased conditions, constant-key element accesses and inferred predicates can silently disappear when someone changes a `const` to a `let` or adds a second `return` to a helper. Across a large codebase, how do you decide where to rely on inferred narrowing and where to make the narrow type explicit?

level: principalimportance: nice to knowfreq 22%

answer

  1. the guarantee is derived, not declared
  2. errors land far from the edit
  3. locality decides how much ceremony is worth it
  4. collapse the union at the boundary
  5. assertions turn compile errors into run-time ones

basics

~20 s

Rely on inferred narrowing where the check and its use are visible together inside one function, and make the narrow type the declared type wherever data crosses a module or API boundary, so the guarantee lives in a signature rather than in a body someone can edit.

solid answer

~50 s

My rule is locality. Inside a single function, where the alias, the check and the use fit on one screen, inferred narrowing is pure ergonomics and I let people use it freely — a broken refactor surfaces immediately, in the same file, at the same review. Across a boundary it is a liability, because the guarantee is not written anywhere: a helper that loses its inferred predicate, or a `const` that becomes a `let`, produces errors in distant callers while the edited file stays clean. So at module and API boundaries I want the narrowed type to *be* the declared type — validate once and return `Config` rather than `Config | undefined`, or state the relationship in the signature. The failure mode I actually police in review is people restoring compilation with assertions instead of asking why the narrowing vanished, because that converts a compile error into a run-time one.

go deeper

for a junior

Know that narrowing can stop working after a refactor and that the fix is to find what changed, not to add an assertion to make the error go away. Ask when you are unsure why a value is suddenly possibly undefined.

for a middle

Be able to say which specific edits remove narrowing — a const becoming a let, an added return-type annotation, a second return in a helper — and explain that the guarantee lives in the code's shape rather than in any signature.

for a senior

Argue the boundary case with evidence: show how a lost inference produces errors in distant callers while the edited file compiles, and propose collapsing the union at the validation edge so downstream code never holds the wide type.

for a principal

Own the policy and its cost. Decide where inference is acceptable ergonomics and where an invariant must be declared, account for published surfaces and pinned compiler versions, and make the review heuristic explicit rather than relitigating it per pull request.

## What these features have in common Control-flow analysis of aliased conditions, narrowing through constant-key element accesses, and inferred type predicates are three separate conveniences, but they share one property that matters for codebase strategy: **the guarantee is derived, not declared.** Nothing in a signature or a type annotation records that `isReady` implies `user` is loaded, or that this helper refines its argument. The compiler works it out from the shape of the code each time it checks. Derived guarantees have a specific failure mode. They break on edits that look innocuous — `const` to `let`, adding an early return, adding a `: boolean` annotation for "clarity", introducing an assignment to a parameter. And they break *elsewhere*: the file you edited still compiles, while callers you have never opened start reporting that a value may be undefined. ## The locality rule The distinction I use is whether the check and its consumer are visible together. **Within one function**, inferred narrowing is the right default. The alias, the condition and the use are in the same body; if a refactor breaks the inference, the error lands next to the change, in the same review, usually within seconds in the editor. There is no cost to being wrong here beyond a moment's confusion, and the alternative — ceremony around every local check — makes code worse. **Across a boundary** — an exported function, a module's public surface, anything another team consumes — I want the invariant in the type. Concretely: validate at the edge and return the narrow type, so callers never hold the wide one. A parser that returns `Config` instead of `Config | undefined`, or throws, removes the question entirely. Where a boolean check genuinely must cross the boundary, the relationship belongs in the signature rather than being left to inference, because a signature is a contract that a change to the body cannot silently revoke. ## Why not just declare everything Because declarations that a human writes are also promises the checker will not verify, and each one is a place the code and the claim can drift apart. Erasure is the reason: none of this exists at run time, so no annotation is validated by execution. A codebase that reflexively hand-declares every relationship trades one risk (inference silently disappearing) for another (an assertion that was true when written and is false now), and pays in ceremony besides. The narrow-type-at-the-boundary approach avoids both, because there is nothing left to promise once the wide type never escapes the edge. ## What I actually enforce - **Boundaries return narrow types.** Parsing, validation and adapters are where the union collapses; downstream code should not be re-checking what was already established. - **A vanished narrowing is investigated, not silenced.** The tell in review is a non-null assertion or a cast appearing in a diff that had nothing to do with nullability. That is a compile error being converted into a run-time one, and it is the single habit most worth naming explicitly. - **Explicit return annotations on exported functions are a deliberate choice, not a blanket rule.** They are good documentation, but on a refining helper an annotation is exactly what turns off inference — so the team should know that writing `: boolean` there is a decision with a consequence. - **Version awareness.** These behaviours arrived in specific compiler versions, so a monorepo pinning an older TypeScript, or a published package supporting a range of consumer versions, cannot assume callers get the same inference. Anything shipped as a `.d.ts` should state its guarantees rather than hope the consumer's compiler derives them. ## The wider judgment The deeper trade is between *proving* an invariant repeatedly and *establishing* it once. Narrowing is proof-at-each-use: cheap, local, and re-derived every time the checker runs. Making the narrow type the declared type is establish-once: it costs a validation boundary and a type to name, and in exchange every downstream reader gets the guarantee for free and no refactor can quietly take it away. Small, local, fast-moving code favours the first. Long-lived shared code — anything with callers you will not personally fix — favours the second. Most of the pathologies I see in review come from applying the local answer at a boundary: a wide type spreading through a system, each consumer re-checking it, each check depending on inference that one edit can remove. ## How I would introduce this to a team Not as a lint rule, because none of this is mechanically detectable in a useful way. As a review heuristic with two questions: *would a reader of this signature know the invariant?* and *if this inference disappeared, where would the errors show up?* If the answers are "no" and "in someone else's module", the invariant belongs in the type.

  • How does this change for a library you publish as .d.ts files?
    It tightens considerably. Consumers compile with their own TypeScript version and their own flags, so anything you leave to inference may simply not be derived on their side. Published surfaces should state their guarantees in the emitted signatures and return already-narrowed types, so the contract does not depend on which compiler reads it.
  • A reviewer wants a rule that every exported function annotates its return type. What is your response?
    Good default, one exception worth naming: on a helper that refines its parameter, an explicit `boolean` annotation is precisely what suppresses the inferred predicate and breaks callers. So the rule should be that exported functions declare their return type deliberately — which for a refining helper means stating the refining relationship, not writing `boolean` and moving on.
  • How do you spot in review that someone papered over a lost narrowing?
    Look for assertions and casts appearing in a diff whose stated purpose has nothing to do with nullability or unions. That combination almost always means the compiler objected after an unrelated edit and the author silenced it. The right move is to find which property of the code the inference depended on and restore it, or lift the invariant into the type.

saying these in an interview costs you the question

  • Treats inferred narrowing as a contract callers can depend on
  • Silences lost narrowing with assertions instead of diagnosing it
  • Wants every relationship hand-declared regardless of cost
  • Assumes all consumers compile with the same TypeScript version
  • Thinks narrowing choices affect run-time performance

context