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?
answer
- a named condition, not just an inline one
- the keyword on the alias matters
- annotating the alias changes what it means
- both sides must be immutable to the checker
- TypeScript 4.4 aliased conditions
basics
~20 sYes. 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 sTypeScript 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 linesfunction 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
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.
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.
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.
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