In TypeScript, `const greeting = 'hi'` is inferred as the literal type `'hi'`, but `let greeting = 'hi'` is inferred as `string`. Why do the two differ, and how do you keep a literal type on a mutable variable?
answer
- mutability decides precision
- can this binding change later
- literal collapses to its base type
- annotate to cap what it may hold
basics
~20 sTypeScript widens a literal only for mutable bindings: const greeting = 'hi' keeps the literal type 'hi', while let greeting = 'hi' widens to string since it can be reassigned. Annotate the let to keep a literal type.
solid answer
~40 sInference asks whether the binding can ever hold something else. A `const` can never be reassigned, so the compiler keeps the most precise type available — the literal type `'hi'`. A `let` can be reassigned, so keeping `'hi'` would make every later assignment an error; the compiler widens the literal to its base primitive, `string`. The same rule applies to `42` vs `number` and `true` vs `boolean`, and to object literal properties, whose slots are mutable: `const config = { mode: 'fast' }` infers `{ mode: string }`. To keep precision on a mutable binding, give it a declared type: `let mode: 'fast' | 'slow' = 'fast'`. That declared type becomes the ceiling, assignments outside it are errors, and the compiler still tracks which member it currently holds.
code
typescript · 10 linesconst greeting = "hi"; // type: "hi"
let message = "hi"; // type: string
const config = { mode: "fast" }; // { mode: string }
let mode: "fast" | "slow" = "fast"; // annotation keeps it precise
mode = "slow";
const copied = greeting; // "hi"
let moved = greeting; // string — widens at the mutable slotgo deeper
Be able to state the rule on the spot: const keeps the exact literal type, let widens it to string, number or boolean. Say plainly that an annotation is how you keep a mutable variable precise.
Explain that widening happens wherever the value lands in a mutable slot, which is why object literal properties widen too, and why copying a const into a let loses the literal type.
Show where this surfaces in real code — a value that is obviously valid rejected by a union-of-literals parameter — and read the compiler message as a statement about the variable's type rather than its value.
Discuss it as an API design lever: exposing unions of literals forces callers into precise bindings, so decide deliberately whether a public surface should demand that precision or accept a widened primitive and validate.
## Two inferences from the same initializer When TypeScript infers a variable's type from its initializer, it first asks whether the binding can ever hold a different value. ```ts const greeting = "hi"; // type: "hi" let message = "hi"; // type: string ``` A `const` binding can never be reassigned, so the compiler is free to keep the most precise type it can name: the *string literal type* `"hi"`, a type whose only member is the string `hi`. A `let` binding can be reassigned. If the compiler kept `"hi"` there, every later `message = "bye"` would be an error and the variable would be unusable, so the literal is **widened** to its base primitive type, `string`. The rule is uniform across primitives: ```ts const n1 = 42; // 42 let n2 = 42; // number const b1 = true; // true let b2 = true; // boolean ``` ## Widening happens at the mutable location The literal types produced by inference are widening literal types: they collapse to the base type wherever they land in a mutable slot. That is why widening also reaches into object literals, whose properties are mutable by default: ```ts const config = { mode: "fast" }; // { mode: string } config.mode = "slow"; // allowed — the property is string ``` And why copying a `const` into a `let` loses the literal: ```ts const a = "hi"; // "hi" let b = a; // string ``` ## Why the difference is visible in real code The distinction shows up the moment a value flows into a parameter that expects a union of literals: ```ts declare function setMode(mode: "fast" | "slow"): void; const fixed = "fast"; setMode(fixed); // OK — fixed is "fast" let current = "fast"; setMode(current); // Error: Argument of type 'string' is not // assignable to parameter of type '"fast" | "slow"'. ``` The error message is the giveaway: the compiler is not complaining about the *value*, which is perfectly valid, but about the widened *type* of the variable that carries it. Nothing about the runtime differs; the two calls emit identical JavaScript. ## Keeping precision on a mutable binding The fix is to give the mutable binding a declared type instead of relying on inference: ```ts let current: "fast" | "slow" = "fast"; setMode(current); // OK current = "slow"; // OK — inside the declared type // current = "turbo"; // Error — outside the declared type ``` The annotation does two jobs at once. It caps what the variable may ever hold — the declared type is the ceiling for every future assignment — and it lets control-flow analysis track which member of the union the variable currently holds, so a `switch` or comparison on `current` still narrows. For object literals the equivalent tool is a declared type on the variable (`const config: { mode: "fast" | "slow" } = { mode: "fast" }`), or a const assertion, which is its own topic. ## Common traps - **`const` is not deep.** `const` freezes the *binding*, not the object. A `const` object's properties still widen and are still writable; only the variable cannot be re-pointed. - **An annotation on a `const` throws precision away.** `const mode: string = "fast"` gives `string`, not `"fast"` — the declared type always wins over inference. - **Widening is not a runtime behaviour.** Types are erased; `let` and `const` differ at runtime only in JavaScript's own binding and scoping rules, which are a separate subject. Everything above is purely what the checker sees. ## The mental model Inference gives you the most useful type, not the narrowest possible one, and "useful" depends on mutability. Immutable binding, precise type; mutable binding, room to move. When you want a mutable binding to *also* be precise, you must say so with an annotation — the compiler will not guess a restriction you did not write.
- Does the same widening happen to the properties of an object assigned to a const variable?Yes. `const` protects the binding, not the contents. `const config = { mode: 'fast' }` infers `{ mode: string }`, because the property slot is mutable and can be reassigned later. To keep the property precise you have to say so — annotate the variable with an explicit type such as `{ mode: 'fast' | 'slow' }`, or make the property `readonly` in a declared type.
- If I write `const mode: string = 'fast'`, what type does mode have?`string`. An explicit annotation always wins over inference, so the literal type is discarded before widening is even considered. This is a common accidental precision loss: people add `: string` for readability and then wonder why the value no longer satisfies a `'fast' | 'slow'` parameter. Drop the annotation, or annotate with the union you actually mean.
- Why does the compiler widen at all instead of inferring the literal everywhere and widening on demand?Because a mutable binding whose type is a single literal is unusable — the first reassignment would fail, and inference that produces an immediately broken variable is worse than a slightly looser type. Widening encodes the assumption that a `let` exists in order to change. Where that assumption is wrong, an explicit declared type states the real constraint.
saying these in an interview costs you the question
- Claims const and let infer the same type
- Thinks const makes an object's properties immutable
- Says the widening is a runtime difference between const and let
- Believes annotating const with : string preserves the literal type
- Assumes a let can never hold a literal type