skip to content

In TypeScript 5.5 or later, `if (isDefined(v)) { v.trim(); }` narrows `v` when the helper is written `function isDefined(x: string | undefined) { return x !== undefined; }`, but reports that `v` may be undefined once you annotate that helper's return type as `: boolean`. What is going on?

level: middleimportance: must knowfreq 55%

answer

  1. control flow stops at the function boundary
  2. a boolean says nothing about which argument
  3. the checker can sometimes infer the relationship
  4. an annotation is taken at face value
  5. one return, no mutation, no annotation

basics

~20 s

A boolean-returning helper narrows nothing by itself. TypeScript 5.5 infers a type predicate for a helper that has no return-type annotation, exactly one return statement, an unmutated parameter, and a body whose result refines that parameter. Annotating : boolean suppresses the inference.

solid answer

~50 s

Control-flow analysis is local to a function body, so historically a helper that returned `boolean` told the call site nothing — only a declared type predicate (`x is string`) crossed the boundary. TypeScript 5.5 added *inferred type predicates*: when a function has no return-type annotation, contains exactly one `return` and no implicit ones, never assigns to its parameter, and returns an expression that refines that parameter, the checker gives it a predicate signature automatically. So `isDefined` above is inferred as `(x: string | undefined) => x is string` and narrows `v` at the call site. Writing `: boolean` opts out — an explicit annotation is taken at face value, the signature becomes an ordinary boolean-returning one, and the call site learns nothing. The same inference is why `array.filter(x => x !== null)` now yields a non-nullable element type.

code

typescript · 19 lines
typescript
// TypeScript 5.5+: predicate inferred as (x: string | undefined) => x is string
function isDefined(x: string | undefined) {
  return x !== undefined;
}

// Annotated: signature is a plain boolean, so no narrowing at the call site
function isDefinedAnnotated(x: string | undefined): boolean {
  return x !== undefined;
}

function use(v: string | undefined) {
  if (isDefined(v)) {
    console.log(v.trim()); // ok
  }
  if (isDefinedAnnotated(v)) {
    // @ts-expect-error 'v' is possibly 'undefined'
    console.log(v.trim());
  }
}

go deeper

for a junior

Recall that pulling a check into a helper can lose the narrowing you had inline, and that the helper's signature is what the call site sees. Be able to say that a plain boolean return does not tell the caller which value was checked.

for a middle

Explain that control-flow analysis is local to one function body, then list the conditions under which TypeScript 5.5 infers a predicate: no return-type annotation, exactly one return, no mutation of the parameter, and a returned expression that refines it. Show a variant that fails each condition.

for a senior

Debug it in a real codebase: given callers that suddenly report possibly-undefined, trace back to the helper edit that dropped the inferred predicate. Explain why an inferred predicate is fragile for exported functions and what you would require on a shared boundary instead.

for a principal

Set the policy. Inferred narrowing is convenient inside a module and risky across one, because the guarantee lives in a body rather than a signature and moves errors far from the edit that caused them. Decide where the team declares the relationship and where inference is good enough.

## Why extracting a check used to break narrowing TypeScript's narrowing is a *control-flow* analysis performed within a single function body. It watches the conditions your code branches on and refines the types of references it can see. It is not interprocedural: the checker does not inline a helper's body to work out what its `true` result implies about the argument you passed. So for most of TypeScript's history, this was a hard boundary: ```ts function isDefined(x: string | undefined) { return x !== undefined; } function use(v: string | undefined) { if (isDefined(v)) { v.trim(); // pre-5.5 error: 'v' is possibly 'undefined' } } ``` The signature said `(x: string | undefined) => boolean`. A boolean carries no information about *which* argument it is a statement about, so the call site had nothing to narrow with. The long-standing remedy was to declare a type predicate in the return position, which makes the relationship explicit in the signature. ## What TypeScript 5.5 added TypeScript 5.5 infers those predicates in the common cases. When checking a function without a return-type annotation, the compiler asks whether the body is simply a refinement of one of its parameters; if so, it synthesises a predicate signature. `isDefined` above is inferred as `(x: string | undefined) => x is string`, and the call site narrows exactly as if you had written the predicate yourself. The headline consequence is that ordinary array pipelines type themselves: ```ts const raw = [1, 2, 3].map(n => (n % 2 === 0 ? n : null)); const evens = raw.filter(n => n !== null); // number[] in 5.5+, (number | null)[] before ``` ## The conditions for inference The inference is deliberately narrow, because a wrong predicate is worse than no predicate — it hands the checker a promise it will trust without re-checking. - **No explicit return type.** If you write `: boolean`, the compiler takes your annotation as the signature. An annotation is a declaration of intent, and the compiler does not second-guess it. This is the specific behaviour in the question. - **Exactly one `return`, and no implicit returns.** A body with an early `return false` and a later `return true` is not analysed, even though a human can see it is equivalent. An arrow function with an expression body counts as the single return. - **The parameter is not mutated.** If the body assigns to the parameter, the expression being returned may be about a different value than the caller passed, so the relationship no longer holds. - **The returned expression must refine that parameter.** The compiler works out the type the parameter has when the expression is `true`; if that type is narrower than the declared one, it becomes the predicate. ```ts function a(x: string | undefined) { return x !== undefined; } // x is string function b(x: string | undefined): boolean { return x !== undefined; } // plain boolean function c(x: string | undefined) { // plain boolean if (!x) return false; return true; } function d(x: string | undefined) { // plain boolean x = x ?? ""; return x !== undefined; } ``` ## What this does and does not buy you A predicate — inferred or declared — is a *promise the compiler trusts*. Types are erased, so there is no verification at run time and no cost either: the emitted JavaScript is just your comparison. If the body's logic is wrong, or if you later change the body without changing the (now explicit) annotation, the checker keeps narrowing on a false premise and you get a crash where the type said you could not. There is also a subtle refactoring hazard in the inference itself. Because the predicate is *inferred*, it is not written anywhere. Adding an early return to the helper for an unrelated reason silently downgrades the signature to `boolean`, and the errors appear in every caller rather than at the change. Nothing in the helper's own file complains. ## How to reason about it in review When someone reports "my check stopped narrowing after I extracted it", run the list: is there a return-type annotation, is there more than one return, does the body assign to the parameter, and does the returned expression actually refine the parameter rather than some derived value? If the narrowing genuinely matters to callers, the honest move is to state the relationship in the signature rather than depend on an inference that a future edit can remove without warning.

  • Why is the inference restricted to functions with a single return statement?
    To keep it safe and cheap. With one return, the compiler evaluates the parameter's type under the assumption that the returned expression is true, and that type is the predicate. With several returns it would have to reconcile the conditions along every path, and a mistake would produce a predicate the checker trusts without any run-time verification.
  • If a predicate is inferred rather than written, what happens when someone edits the helper later?
    The signature silently degrades. Adding an early return or assigning to the parameter drops the predicate back to `boolean`, and every caller that depended on the narrowing starts erroring while the edited file itself stays clean. That invisibility is the main argument for declaring the relationship explicitly on anything exported.
  • Does a predicate — inferred or declared — cost anything at run time?
    No. The type layer is erased entirely: the emitted JavaScript contains only the comparison you wrote in the body. That is also why it is a promise rather than a guarantee — nothing re-checks the claim, so a body whose logic does not match its predicate produces a run-time failure the checker will not have flagged.

saying these in an interview costs you the question

  • Says any function returning boolean narrows its argument
  • Thinks the compiler inlines helper bodies to narrow
  • Believes the annotation : boolean is always harmless
  • Assumes a predicate is verified at run time
  • Adds a non-null assertion at the call site instead of fixing the helper

context