With `noImplicitAny` enabled, why does the TypeScript declaration `let value;` compile without an implicit-any error, and what type does `value` have after `value = 1;`?
answer
- no annotation is not the same as any
- the compiler is not giving up here
- type computed from what was assigned
- annotating it turns the tracking off
basics
~20 sA variable declared with no annotation and no initializer gets an evolving type rather than a fixed any: TypeScript infers it at each use from the assignments that reach that point. After value = 1, reads of value see number.
solid answer
~50 s`noImplicitAny` fires when the compiler has to give up and settle on `any`. For `let value;` it does not have to: the variable gets an *evolving* type, and control-flow analysis computes it at each reference from the assignments on the paths reaching that reference. So after `value = 1`, `value.toFixed(2)` compiles and `value.toUpperCase()` does not. Assign a string on one branch and a number on another and the code after the join sees `string | number`. The same treatment applies to `let value = null` and `let value = undefined`, and to `let items = []`, whose element type grows from the pushes it receives. Two limits matter: reading the variable on a path where nothing has been assigned is an error, and adding any annotation — even `: any` — switches the variable to ordinary declared-type behaviour and turns the evolution off.
code
typescript · 11 lineslet value;
value = "text";
console.log(value.toUpperCase()); // string
value = 1;
console.log(value.toFixed(2)); // number
const items = [];
items.push(1);
items.push("two");
console.log(items.length); // (string | number)[]go deeper
Know that leaving a local variable untyped is not the same as writing any, and that the compiler still checks what you do with it after an assignment.
Explain that the type is recomputed at each reference from the assignments reaching it, that branches union at the join, and that an annotation switches the variable back to fixed declared-type behaviour.
Recognise the pattern in legacy code during a migration and know why an explicit union plus guards is the better landing point, since an evolving variable documents nothing and constrains no future assignment.
Decide the policy: whether the codebase tolerates untyped locals during migration and what the exit criterion is, given that the feature quietly weakens the contract a reviewer can rely on.
## The exception to noImplicitAny `noImplicitAny` reports an error whenever the compiler would silently fall back to `any` because it cannot infer anything better. A variable with no annotation and no initializer looks like exactly that case, yet it compiles: ```ts let value; // no error, even with noImplicitAny value = 1; value.toFixed(2); // OK — value is number here ``` The reason is that the compiler is not falling back. Instead of fixing the type at the declaration, it gives the variable an **evolving** type: the type at any reference is computed from the assignments that flow into that reference. The variable's type is a function of position, exactly like a narrowed union. ## How the evolution works ```ts let value; // value: undefined here — nothing assigned yet value = "text"; value.toUpperCase(); // string value = 1; value.toFixed(2); // number // value.toUpperCase(); // Error: Property 'toUpperCase' does not exist on type 'number'. ``` At a join point the incoming types are unioned, as with any other flow analysis: ```ts let value; if (flag) { value = "text"; } else { value = 1; } value; // string | number ``` The same treatment applies when the initializer carries no information: ```ts let a = null; // evolving let b = undefined; // evolving ``` and to empty array literals, whose element type grows from the operations performed on them: ```ts const items = []; // evolving array items.push(1); items.push("two"); items; // (string | number)[] ``` ## What ends the evolution **Any annotation.** The moment you write a type, the variable has a declared type and behaves like every other variable — a fixed ceiling with narrowing inside it: ```ts let value: any; // plain any, no evolution, no checking value = 1; value.nonsense(); // no error — any silences everything ``` That is the point worth internalising: `let value;` is *safer* than `let value: any;`, because the untyped form still tracks what was assigned while the annotated form disables checking entirely. **Reading before assignment.** Using the variable on a path where nothing has reached it is an error rather than a silent `any`: ```ts let value; if (flag) value = 1; // value.toFixed(2); // Error — on the other path nothing was assigned ``` ## Why you should still usually annotate The feature exists to make untyped code and gradual migrations behave sensibly, not as a style to adopt. Relying on it has real costs: - The variable's contract is invisible. A reader must trace every assignment in the function to learn what it may hold, which is exactly the work an annotation removes. - Nothing constrains future assignments. Because there is no declared ceiling, a later `value = someUnrelatedThing` is accepted and simply changes the type from that point on — no error tells you the variable's purpose drifted. - It only works for straight-line, single-function flow. Once the variable is used in a position the analysis cannot follow, you are back to a loose type with no annotation to fall back on. The idiomatic form for a value assigned in branches is a declared union plus terminating guards, or a `const` initialised from a conditional expression: ```ts const value = flag ? "text" : 1; // string | number, immutable, obvious ``` ## The interview point The question is really testing whether you know that `noImplicitAny` is about *inference failure*, not about the absence of annotations, and that `any` and "no annotation" are different things in TypeScript. A candidate who answers "it's an error, that's what the flag is for" has the wrong model of what the flag detects.
- Is `let value;` therefore better than `let value: any;`?For type safety, yes — the untyped form still tracks assignments and rejects wrong member access, while `: any` disables checking on that variable completely. But neither is a good default. A declared type documents intent and constrains future assignments; the untyped form leaves both to whoever reads the function next.
- What happens if the variable is read on a branch where nothing has been assigned?That is an error rather than a silent `any`. The analysis knows which assignments reach the reference, and on a path where none do there is no type to use. This is the same reachability reasoning that powers guard clauses, applied to a variable whose type comes entirely from its assignments.
- Does the evolving behaviour apply to class fields and parameters too?No — it is a local-variable feature. A parameter with no annotation is a genuine implicit `any` and does error under `noImplicitAny`, because there is no assignment flow inside the function to infer from. Class fields need an annotation or an initializer for the same reason: the compiler has no single flow to follow.
saying these in an interview costs you the question
- Says noImplicitAny always errors on a missing annotation
- Treats let x; and let x: any; as equivalent
- Thinks the variable stays any after the first assignment
- Assumes an unannotated parameter evolves the same way
- Believes reading it before assignment yields any