skip to content

In TypeScript, given `function f(x: string | number | undefined)`, what type does x have in each branch of `if (x) { ... } else { ... }`, and why does the compiler not complain that 0 and the empty string end up in the else branch?

level: middleimportance: must knowfreq 72%

answer

  1. narrowing works on types, not values
  2. only always-falsy members are removed
  3. else branch still holds string and number
  4. 0 and "" have no type of their own
  5. ask about presence, not truthiness

basics

~20 s

Truthiness narrowing removes only constituents that can never be truthy, so the then-branch is string | number and the else-branch stays string | number | undefined. Since 0 and "" are ordinary values of number and string, the checker sees nothing wrong.

solid answer

~50 s

A truthiness check narrows by removing union members, not by inspecting values. In the true branch TypeScript drops every constituent that is always falsy — here `undefined` — leaving `string | number`. In the false branch it can only drop constituents that are always truthy, and neither `string` nor `number` qualifies because `""`, `0` and `NaN` are perfectly good members of them, so the else branch keeps the full `string | number | undefined`. That is exactly why the classic bug is invisible to the compiler: you meant "is it present?" but you asked "is it truthy?", and both questions are type-correct. If the union were literal types — `0 | 1 | 2` — narrowing would be precise, giving `1 | 2` and `0`. For presence checks write `x !== undefined` or `x != null` instead.

code

typescript · 18 lines
typescript
function f(x: string | number | undefined) {
  if (x) {
    const a: string | number = x; // undefined removed
    void a;
  } else {
    const b: string | number | undefined = x; // 0 and "" land here too
    void b;
  }
}

function g(x: string[] | undefined) {
  if (x) {
    x.push("ok");
  } else {
    const c: undefined = x; // arrays are never falsy
    void c;
  }
}

go deeper

for a junior

Know that if (x) is false for 0, "" and NaN as well as null and undefined, and be able to say which types disappear from the truthy branch.

for a middle

Explain the mechanism in both directions: always-falsy constituents leave the true branch, always-truthy ones leave the false branch, and string/number leave neither. Give the branch types out loud.

for a senior

Show the production instinct: name the class of bug (in-band falsy values treated as absent), and say when you would still allow a truthiness check and how you would make the intent explicit in review.

for a principal

Own the modelling stance — whether the codebase permits falsy sentinels at all, how boundaries normalise them, and what you trade away by enforcing presence checks everywhere versus tolerating the idiom.

## What narrowing means here *Narrowing* is the compiler replacing a variable's declared type with a more specific one inside a branch, because a condition it recognises proved something about the value. A bare truthiness test — `if (x)`, `if (!x)`, `x && ...`, the condition of a ternary — is one of the conditions TypeScript recognises. Crucially it reasons about **types**, which are sets of values, not about the values themselves. ## The rule, stated precisely In the **true** branch, TypeScript removes every union constituent that can never be truthy: `undefined`, `null`, and the falsy literal types `false`, `0`, `-0`, `0n`, `""`. In the **false** branch, it removes every constituent that can never be falsy: object types, array types, function types, `true`, and non-zero / non-empty literal types. Everything else survives in both branches, because the checker cannot rule it out. Apply that to `string | number | undefined`: ```ts function f(x: string | number | undefined) { if (x) { x; // string | number (undefined is always falsy, so it goes) } else { x; // string | number | undefined } } ``` The else branch is the interesting half. `string` cannot be removed because `""` is a string; `number` cannot be removed because `0` and `NaN` are numbers. The declared type survives untouched, and the empty string and zero quietly flow into a branch usually written as "the value was missing". ## Why there is no error A type is a set of values. `0` is a member of `number`; there is no built-in "non-zero number" type for the checker to narrow to. So `if (x)` is a perfectly well-typed expression that asks a legitimate question — it just is not the question you meant. The compiler cannot infer intent, and it has no runtime presence at all: types are erased, so the emitted JavaScript is the same `if (x)` and the runtime falsiness rules decide everything. That is the bridge worth stating in an interview: *which* values are falsy is a JavaScript-runtime fact; the TypeScript question is what the checker does with a condition built on it, and the answer is "it removes types, never values". ## Where truthiness narrowing is precise When the falsy possibilities have their own types, narrowing is exact: ```ts declare let n: 0 | 1 | 2; if (n) { n; // 1 | 2 } else { n; // 0 } declare let b: boolean; if (b) { b; /* true */ } else { b; /* false */ } declare let list: string[] | undefined; if (list) { list; /* string[] */ } else { list; /* undefined */ } ``` The array case works in the else branch only because an array object is always truthy, so `string[]` can be struck out. Note this also means `if (list)` says nothing about whether the array has elements — a non-empty check is `list.length > 0`, and the type layer will not help you there. ## NaN `NaN` is falsy, and it has no type of its own: it is just a `number`. So `if (x)` silently sends `NaN` down the else path while the static type still says `number` in both branches. Detect it explicitly with `Number.isNaN(x)`; equality comparison against `NaN` is never true. ## What to write instead When the question is presence, ask about presence: ```ts function g(x: string | number | undefined) { if (x !== undefined) { x; // string | number, and "" and 0 are still here } } const label = (s: string | undefined) => s ?? "(none)"; // keeps "" ``` `x !== undefined` and `x != null` narrow exactly the same union members as `if (x)` did for the nullish part, but leave the in-band falsy values alone. Reserve truthiness for values whose falsy members you genuinely want in the same bucket — and say so in a comment when you do. ## The interview framing Strong candidates answer in two moves: state the branch types, then explain the mechanism ("removes always-falsy constituents in the true branch, always-truthy ones in the false branch") and immediately point out that `string` and `number` are removable from neither. Weak candidates say the else branch is `undefined`, which is only true for object-ish unions.

  • What does the else branch look like if the parameter is declared `string[] | undefined` instead?
    It narrows to `undefined`. An array is an object and objects are never falsy, so `string[]` can be struck from the false branch. That is why object-ish unions make truthiness feel precise and primitive unions do not. Note the true branch still tells you nothing about the array's length — `if (list)` is true for `[]`.
  • How does truthiness narrowing behave on a union of literal types such as `0 | 1 | 2`?
    Precisely: the true branch is `1 | 2` and the false branch is `0`. Each literal is its own type, so the checker can decide falsiness per constituent and remove the ones that do not fit. The same holds for `boolean`, which splits into `true` and `false`.
  • Where does NaN fit into this picture?
    NaN is falsy at runtime but has no distinct type — it is simply a `number`. So it silently takes the else branch while the static type in both branches still reads `number`, and no narrowing can express "this number is not NaN". Test it explicitly with `Number.isNaN(x)`.

Truthiness narrowing is a bouncer working from a guest list of types, not of people: it can turn away undefined because the whole type is falsy, but number is on the list, so 0 walks straight in.

saying these in an interview costs you the question

  • Says the else branch narrows to undefined for string | number | undefined
  • Claims TypeScript errors when a falsy value is silently dropped
  • Treats if (x) as equivalent to x !== undefined
  • Thinks narrowing inspects the runtime value rather than the type
  • Says if (arr) proves the array has elements

context