skip to content

In TypeScript, if you write `const isString = typeof value === "string";` and then `if (isString) { ... }`, does the compiler still narrow `value` inside the block, and what does it require for that to work?

level: middleimportance: should knowfreq 45%

answer

  1. a named condition, not just an inline one
  2. the keyword on the alias matters
  3. annotating the alias changes what it means
  4. both sides must be immutable to the checker
  5. TypeScript 4.4 aliased conditions

basics

~20 s

Yes. Since TypeScript 4.4 the checker follows a condition stored in a const and re-applies it wherever that const is used, provided the alias is a const without a type annotation and the value it tests is itself effectively constant.

solid answer

~50 s

TypeScript 4.4 added control-flow analysis of aliased conditions. When the checker meets `if (isString)`, it resolves `isString` to its declaration, and if that declaration is a `const` with an initializer, it re-runs its normal narrowing logic on that initializer expression as though you had written `typeof value === "string"` inline. Three things have to hold: the alias must be declared with `const` (a `let` never qualifies, even if you never reassign it); the alias must have no explicit type annotation, because `const isString: boolean = ...` makes `boolean` the declared type and the initializer stops being consulted; and the reference being tested must be a constant one — a `const` variable, a `readonly` property, or a parameter that is never assigned inside the function. Break any of the three and you get the old behaviour: the `if` sees a plain boolean and `value` keeps its wide type.

code

typescript · 14 lines
typescript
function f(value: unknown) {
  const isString = typeof value === "string";
  if (isString) {
    console.log(value.toUpperCase()); // value: string
  }
}

function g(value: unknown) {
  const isString: boolean = typeof value === "string";
  if (isString) {
    // @ts-expect-error the annotation cuts the link to value
    console.log(value.toUpperCase());
  }
}

go deeper

for a junior

Know that you can pull a typeof or null check into a named variable and still get narrowing, as long as you declare it with const. Be ready to say that the check itself is ordinary JavaScript and the narrowing is purely a compile-time convenience.

for a middle

Explain the mechanism: the checker resolves the identifier in the condition to its const declaration and re-applies narrowing to the initializer expression. State the three requirements — const, no type annotation, and a constant reference being tested — and show a snippet where each one breaks it.

for a senior

Diagnose it in review. When a colleague reports that narrowing broke after an unrelated refactor, walk the three conditions in order and name the likely culprit rather than reaching for a non-null assertion. Explain why the compiler is conservative here instead of doing a whole-function mutation analysis.

for a principal

Own the tradeoff. This is inference ergonomics with no declared contract: it is invisible in the signature, so a one-word edit can move errors to distant call sites. Decide where the team may lean on it and where an invariant belongs in the declared types instead.

## What narrowing is, and why naming a condition used to break it TypeScript's checker refines the type of a reference by following the control flow of your code. A `typeof` test, an equality test against `null`, or a plain truthiness test in an `if` condition tells the checker something about a particular reference, and inside the guarded branch it substitutes the narrower type. All of this is a compile-time bookkeeping exercise: nothing about it survives into the emitted JavaScript, which contains only your original runtime check. For a long time this bookkeeping worked only when the test appeared *literally* in the condition. The moment you gave the condition a name, the link was severed: ```ts function f(value: unknown) { const isString = typeof value === "string"; if (isString) { value.toUpperCase(); // pre-4.4 error: 'value' is of type 'unknown' } } ``` The checker saw a `boolean` in the condition and had no record that the boolean's truth said anything about `value`. This punished exactly the refactor most people consider good style — extracting a long condition into a well-named local. ## What TypeScript 4.4 changed TypeScript 4.4 shipped *control-flow analysis of aliased conditions and discriminants*. When narrowing on an identifier used as a condition, the checker now looks up the identifier's symbol; if it resolves to a `const` variable declared with an initializer, it recursively applies its normal narrowing rules to that initializer expression. The snippet above compiles today: inside the `if`, `value` is `string`. The same treatment applies to an aliased **discriminant**, which is where the feature earns most of its keep: ```ts type Shape = | { kind: "circle"; radius: number } | { kind: "square"; side: number }; function area(shape: Shape) { const kind = shape.kind; if (kind === "circle") { return Math.PI * shape.radius ** 2; // shape is the circle member } return shape.side ** 2; } ``` ## The three conditions **1. The alias must be declared with `const`.** The checker only treats a `const` binding as a stable fact. A `let` does not qualify *even if you never reassign it* — the rule is about the declaration keyword, not about a whole-program analysis of assignments. ```ts let isString = typeof value === "string"; if (isString) { value.toUpperCase(); // error: no narrowing, isString is just a boolean } ``` **2. The alias must have no explicit type annotation.** Annotating it makes the annotation the declared type, and the checker stops consulting the initializer: ```ts const isString: boolean = typeof value === "string"; if (isString) { value.toUpperCase(); // error again — the annotation cut the link } ``` This is the surprising one in interviews, because adding an "obviously correct" annotation is normally harmless. **3. The reference being tested must be a constant reference.** That means a `const` variable, a `readonly` property, or a parameter that is never assigned inside the function body. A `let` that is written to anywhere in the function is not a constant reference, so the recorded fact cannot be trusted at the point of use. ## Why the restrictions exist The alias records a fact captured at one point in time and consumes it at another. Between those two points, either side could change. If the alias were a mutable `let`, something could overwrite the boolean with an unrelated value; if the tested reference were a mutable `let`, something could assign a different value to it while the boolean still reads `true`. Rather than attempt a full may-alias analysis, the compiler restricts the feature to bindings whose immutability it can see syntactically. The result is deliberately conservative: it declines to narrow in cases that would in fact be fine, but it does not invent guarantees it cannot back. ## Where the support stops The analysis is a local, syntactic one. It follows a `const` to its initializer inside the same function body. It does not chase the boolean through a mutable object property, and moving the check into a helper function is a different mechanism entirely — a plain boolean return conveys nothing at the call site unless the compiler infers or you declare a type predicate. ## Practical guidance Extract conditions into `const` locals freely, leave them unannotated, and keep the declaration close to its use so the reader can see both. If narrowing stops working after a refactor, check the three conditions in order — someone changed `const` to `let`, added a `: boolean`, or introduced an assignment to the variable you are testing.

  • Why does adding `: boolean` to the alias declaration turn the narrowing off?
    Because the annotation becomes the declared type of the binding. The checker narrows a condition identifier by looking at the initializer of an unannotated `const` declaration; once you write the type yourself, it takes you at your word and treats the alias as an opaque boolean, so nothing connects it back to the tested reference.
  • Does the same support cover an aliased discriminant, such as `const kind = shape.kind`?
    Yes — it shipped in the same TypeScript 4.4 feature. Comparing the aliased discriminant against a literal narrows the original union, so `if (kind === "circle")` gives you the circle member of `shape`. The same immutability rules apply: `kind` must be a `const`, and `shape` must be a constant reference such as a parameter you never reassign.
  • A colleague says it works for them with a `let` they never reassign. What is actually happening?
    They are almost certainly seeing ordinary inline narrowing, or narrowing on a different line than they think. The aliased-condition analysis keys off the `const` declaration itself, not off a proof that no assignment occurs, so a `let` alias does not get it. Changing the declaration to `const` is the fix, and it also documents the intent.

saying these in an interview costs you the question

  • Says the boolean is checked again at runtime
  • Thinks any local variable holding the check narrows
  • Claims a let works as long as you never reassign it
  • Adds a : boolean annotation and expects no change
  • Assumes the alias narrows the value everywhere in the function

context