Capítulo 31 de 36

Chapter 31: Variable Declarations

Core Idea

let/const fix a set of var's well-known JavaScript footguns (function-scoping instead of block-scoping, silent re-declaration, closure-capture-by-reference-not-value in loops) — TypeScript inherits all of this directly from ES2015 semantics, plus type-level destructuring syntax.

Key Concepts

  • var is function-scoped, not block-scoped: a var declared inside an if/for block is visible throughout the entire enclosing function (or module/global scope), not just the block — so reading it right after the block, or even before its own declaration line (hoisted as undefined), doesn't error.
  • var allows silent re-declaration: declaring the same var name multiple times in the same function scope is legal and refers to the same variable — a classic source of accidental variable clobbering, e.g. a nested loop reusing the same loop-counter name as an outer loop by mistake.
  • The classic var + closure-in-a-loop bug: every closure created inside a var-based for loop (e.g. passed to setTimeout) captures the same variable, not a per-iteration snapshot — by the time any of them actually run, the loop has finished and the variable holds its final value. The traditional workaround is an IIFE per iteration to force a fresh binding.
  • let is block-scoped: visible only within its nearest enclosing block (if, for, {}, catch), not leaking to the surrounding function. This alone eliminates the accidental-reuse class of bug.
  • let's temporal dead zone (TDZ): a block-scoped variable exists (in a compiler sense) throughout its whole scope but can't be read or written before its declaration line — accessing it earlier is a compile error in TypeScript (and a runtime error in real ES2015+ engines). Capturing a let in a closure before its declaration is legal syntactically, but actually calling that closure before the declaration runs is illegal.
  • let forbids re-declaration in the same scope (let x; let x; errors), including across var/let mixing in ways that would otherwise conflict — much stricter than var's "just merges into the same variable" behavior.
  • Shadowing: a let in a nested block can reuse an outer name without conflict (a new, distinct binding) — this is what makes a let-based nested loop with the same counter name in both loops behave correctly, unlike the equivalent var version, which silently clobbers the outer counter.
  • let in a loop creates a fresh binding per iteration: this is exactly what fixes the classic setTimeout-in-a-loop bug without needing an IIFE — each iteration's closure captures its own let i, not a single shared variable.
  • const: same block-scoping and TDZ rules as let, but forbids reassignment of the binding itself. This is not deep immutability — a const object's own properties remain freely mutable (const obj = {...}; obj.field = "x" is fine; obj = {...} is not). Use readonly properties (or Readonly<T>/as const) when actual property-level immutability is the goal.
  • let vs. const guidance: default to const for anything not intentionally reassigned (principle of least privilege — makes data flow easier to reason about and signals intent to readers); reach for let only for variables genuinely meant to change.
  • Destructuring: array destructuring (const [a, b] = pair) and object destructuring (const { x, y } = point) both work in TypeScript exactly as in JS, plus optional type annotations on the whole pattern (annotations can't go inside individual destructured names — see Object Types for why). Supports rest elements (const [first, ...rest] = list), default values for missing/undefined fields, renaming during object destructuring, and destructuring directly in function parameter lists.

Code Examples

// The classic var-in-a-loop bug and its let fix:
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0); // logs 3, 3, 3 — one shared `i`
}
for (let j = 0; j < 3; j++) {
  setTimeout(() => console.log(j), 0); // logs 0, 1, 2 — fresh `j` per iteration
}
  • What it demonstrates: let's per-iteration binding eliminating the shared-variable closure bug that var requires an IIFE to work around.
const point = { x: 1, y: 2 };
point.x = 5;      // OK — const only locks the binding, not the object's contents
point = { x: 0, y: 0 }; // Error — can't reassign a const
  • What it demonstrates: const preventing rebinding while leaving the referenced object's own mutability untouched.

Reference Tables

Featurevarletconst
Scopefunctionblockblock
Re-declarable in same scopeyesnono
Temporal dead zoneno (hoisted as undefined)yesyes
Reassignableyesyesno
Fresh binding per loop iterationnoyesn/a (loop var can't be const-reassigned)

Key Takeaways

  1. Default to const; use let only where reassignment is actually intended; avoid var in new code entirely — its function-scoping and re-declaration permissiveness are pure liability in modern TypeScript.
  2. Remember const is shallow — it locks the binding, not the referenced value's contents; reach for readonly/Readonly<T>/as const for real immutability.
  3. The var-in-a-loop closure bug (setTimeout printing the same final value repeatedly) simply doesn't happen with let — no IIFE workaround needed.

Connects To

  • Everyday Types: as const for freezing literal types, readonly for property-level immutability.
  • Object Types: why destructuring type annotations go after the whole pattern, not inline on individual names.
  • More on Functions: parameter destructuring syntax specifically.