Chapter 18

var, let, const: Spec-Level Differences and the Temporal Dead Zone

The modern JavaScript declaration principle: const by default, let when reassignment is needed, var never

var, let, and const are not merely "old vs. new syntax." At the ECMAScript specification level they correspond to three distinct binding-creation mechanisms, different hoisting behaviors, and different scope rules. Understanding these differences explains an entire category of confusing runtime behaviors—including why let in a for loop solves the closure trap, why typeof throws on a TDZ variable, and why the properties of a const-declared object can still be modified.

The engineering principle for choosing among the three is clear: const is the default because it is the most restrictive and expresses intent most clearly; let is used when reassignment is necessary; var appears only when maintaining legacy code. This is not a style preference—it is a predictability concern. var's function scope and unconstrained hoisting create hidden shared state that is almost always a source of bugs in modern codebases.


🔹 Level 1 · What You Need to Know

const: The Binding Is Immutable; the Value May Not Be

const declares a binding that cannot be reassigned. A binding is the link between a variable name and a value. const prevents "pointing this name at another value," not "modifying the contents of the object this name currently points to":

const x = 42;
x = 100; // TypeError: Assignment to constant variable.

const arr = [1, 2, 3];
arr.push(4);      // OK: modifying the array's contents, not changing what arr points to
console.log(arr); // [1, 2, 3, 4]

arr = [];         // TypeError: attempting to point arr at another object

const obj = { name: 'Alice' };
obj.name = 'Bob'; // OK: modifying an object property
obj = {};         // TypeError: attempting to reassign

const must be initialized at the declaration; you cannot declare first and assign later:

const y;     // SyntaxError: Missing initializer in const declaration
const z = 1; // correct

let: Block Scope, Reassignable

let count = 0;
count = 1; // OK

for (let i = 0; i < 3; i++) {
  // Each iteration: i is an independent binding
  setTimeout(() => console.log(i), 0);
}
// Output: 0, 1, 2 (not 3, 3, 3)

// Compare with var:
for (var j = 0; j < 3; j++) {
  setTimeout(() => console.log(j), 0);
}
// Output: 3, 3, 3 (all closures share the same j)

var: Function Scope, Hoisting, Do Not Use

function example() {
  console.log(x); // undefined (var is hoisted but not yet assigned)

  if (true) {
    var x = 1;    // var is not block-scoped; x belongs to the example function
  }

  console.log(x); // 1
}

example();
console.log(typeof x); // 'undefined' (x is not in global scope)

var declarations are hoisted to the top of the enclosing function (or global scope), but assignments are not. This creates the confusing state of "a variable that exists before its declaration but has the value undefined."

Different Behavior in Global Scope

var a = 1;
let b = 2;
const c = 3;

console.log(window.a); // 1 ← var attaches to window (the global object)
console.log(window.b); // undefined ← let does not attach to window
console.log(window.c); // undefined ← const does not attach to window

🔸 Level 2 · How It Actually Works

Spec-Level Comparison of the Three Binding Forms

Feature var let const
Scope Function (or global) Block Block
Hoisting behavior Hoisted + initialized to undefined Hoisted + TDZ (uninitialized) Hoisted + TDZ (uninitialized)
Duplicate declarations Allowed (same scope) Not allowed Not allowed
Reassignment Allowed Allowed Not allowed
Must be initialized No No Yes
Attaches to globalThis when global Yes No No
TDZ None Present Present
Behavior in for loop Shares a single binding Creates a new binding per iteration Cannot be reassigned in for (compile error)
Deletable No (even global var is not) No No
Corresponding ER type GlobalER[[ObjectRecord]] / FunctionER DeclarativeER DeclarativeER (immutable)

How let Creates a New Binding Per Iteration in a for Loop

for (let i = 0; i < 3; i++) creates a brand-new DeclarativeEnvironmentRecord for each iteration at the specification level:

Iteration 0:
  Create new ER₀, bind i = 0
  Execute loop body (closures capture i from ER₀)
  End of iteration: read i (0), execute i++, get 1

Iteration 1:
  Create new ER₁, bind i = 1 (copied from previous iteration)
  Execute loop body (closures capture i from ER₁)
  End of iteration: read i (1), execute i++, get 2

Iteration 2:
  Create new ER₂, bind i = 2
  Execute loop body
  End of iteration: i++ → 3, fails i < 3 test, loop ends

This is why for (let i=0; i<3; i++) setTimeout(()=>console.log(i)) outputs 0, 1, 2: each iteration's closure captures a different i in a different ER.

┌─────────────────────────────────────────────────────────────┐
│       Relationship Between for-loop ERs and Closures         │
│                                                             │
│  Outer ER of the for loop (forBodyEnv)                      │
│  ┌──────────────────┐                                       │
│  │  forBodyEnv      │                                       │
│  │  i: [overwritten │                                       │
│  │       each iter] │                                       │
│  └──────────────────┘                                       │
│         ↑ each iteration creates a new iterationEnv         │
│                                                             │
│  iter 0: iterationEnv₀ { i: 0 } ←── closure₀.[[Env]]       │
│  iter 1: iterationEnv₁ { i: 1 } ←── closure₁.[[Env]]       │
│  iter 2: iterationEnv₂ { i: 2 } ←── closure₂.[[Env]]       │
│                                                             │
│  When setTimeout callbacks execute asynchronously:          │
│  closure₀ reads iterationEnv₀.i = 0 → prints 0             │
│  closure₁ reads iterationEnv₁.i = 1 → prints 1             │
│  closure₂ reads iterationEnv₂.i = 2 → prints 2             │
└─────────────────────────────────────────────────────────────┘

The Complete Lifecycle of TDZ

TDZ (Temporal Dead Zone) describes the period between the "creation" and "initialization" of a let/const binding:

Lifecycle of a block scope:

  Enter block {}
    ↓
  All let/const declarations are "hoisted"
  (bindings created in uninitialized state)
    ↓
  ← TDZ begins (accessing these variables throws ReferenceError)
    ↓
  ... code in the block executes ...
    ↓
  Execution reaches the let/const declaration line
    ↓
  Initialization expression is evaluated (if present)
    ↓
  ← TDZ ends (binding state: uninitialized → initialized; now accessible)
    ↓
  ... block code continues ...
    ↓
  Exit block {}
    ↓
  ER removed from scope chain (bindings become inaccessible)
{
  // TDZ zone begins
  // console.log(x); // ReferenceError, in TDZ

  let x = 10; // TDZ ends, x initialized to 10

  console.log(x); // 10, normal access
}

typeof Behavior on TDZ Variables

The typeof operator has a special exemption: when applied to an undeclared variable, it returns 'undefined' rather than throwing. But for TDZ variables (let/const declared but not yet initialized), typeof throws ReferenceError:

// Undeclared variable: typeof is safe
console.log(typeof undeclaredVar); // 'undefined' (no error)

// TDZ variable: typeof throws
{
  console.log(typeof x); // ReferenceError! (not 'undefined')
  let x = 1;
}

Specification reason: when typeof is executed, if the operand produces a Reference Record with [[Base]] = unresolvable (an undeclared variable), the spec takes the special "return undefined" path. But for a TDZ variable, GetIdentifierReference finds x's binding (state: uninitialized)—the Reference Record's [[Base]] is a real ER (not unresolvable)—so typeof calls GetValue on that Reference Record, which calls GetBindingValue, which finds the binding uninitialized and throws ReferenceError.

const and Object.freeze

const only guarantees that the binding cannot be reassigned; it does not guarantee the value is immutable. Deep-freezing an object requires recursively applying Object.freeze:

const obj = Object.freeze({ a: 1, b: { c: 2 } });

obj.a = 10;    // silent failure (TypeError in strict mode)
obj.b.c = 20;  // works! b is an object and b itself was not frozen

// Deep freeze
function deepFreeze(obj) {
  Object.getOwnPropertyNames(obj).forEach(name => {
    const value = obj[name];
    if (typeof value === 'object' && value !== null) {
      deepFreeze(value); // recursively freeze nested objects
    }
  });
  return Object.freeze(obj);
}

const frozen = deepFreeze({ a: 1, b: { c: 2 } });
frozen.b.c = 20; // TypeError in strict mode; silent failure in non-strict

🔺 Level 3 · What the Specification Says

Specification Section 14.3: Declarations and the Variable Statement

Section 14.3 (and its subsections) defines how variable and function declarations are processed:

VarDeclaredNames and VarScopedDeclarations: These abstract operations collect all var-declared names during function/script instantiation, then create bindings in the current VariableEnvironment. var bindings are created via CreateMutableBinding(name, false) (D=false, not deletable) and immediately initialized via InitializeBinding(name, undefined).

LexicallyDeclaredNames and LexicallyScopedDeclarations: These collect all let/const/class declarations. These are created via CreateMutableBinding (for let) or CreateImmutableBinding (for const), but InitializeBinding is not called immediately—this is the physical implementation of TDZ.

The function-level hoisting mechanism for var:

FunctionDeclarationInstantiation algorithm (simplified):
1. Collect parameters and create bindings (initialized to argument values)
2. Call InstantiateOrdinaryFunctionObject for inner function declarations
3. For each VarDeclaredName:
   a. Call varEnv.HasBinding(name)
   b. If not present → create binding + InitializeBinding(name, undefined)
   c. If present (already created by a function declaration) → skip (do not overwrite)
4. Execute function body

The per-iteration Scope Creation Algorithm for ForStatement

The specification defines the CreatePerIterationEnvironment operation for for (let/const init; test; update) body:

CreatePerIterationEnvironment(iterationVarNames):
1. Take the LexicalEnvironment of the current running EC as lastIterationEnv
2. Create a new DeclarativeEnvironmentRecord with [[OuterEnv]]
   pointing to lastIterationEnv.[[OuterEnv]]
3. For each iterationVarName:
   a. Create a mutable binding (in the new ER)
   b. Read the current value of that name from lastIterationEnv
   c. Initialize the binding in the new ER with that value
4. Set the current EC's LexicalEnvironment to the newly created ER

This algorithm precisely describes the behavior of "copying the current value into a new ER at each iteration": at the end of iteration 0, i=0; iteration 1 begins by creating a new ER, binding i initialized to 0, then i++ changes this new ER's i to 1…

Key detail: the new ER's [[OuterEnv]] points to lastIterationEnv.[[OuterEnv]] (skipping the previous iteration's ER), not directly to lastIterationEnv. This ensures that iteration ERs do not form a chain of references to each other (once the next iteration starts, if no closure holds a reference to the previous iteration's ER, it can be garbage-collected).

CreateImmutableBinding and the Spec Semantics of const

const declarations use CreateImmutableBinding(N, S) to create a binding with S=true (strict):

The specification requires for DeclarativeEnvironmentRecord.SetMutableBinding: if the binding was created via CreateImmutableBinding (marked as immutable), calling SetMutableBinding (i.e., an assignment) checks the S parameter—if S=true, a TypeError is thrown.

Processing flow for a const declaration:

  1. Parse phase: const declaration detected; recorded as a LexicallyScopedDeclaration
  2. On block entry: call CreateImmutableBinding(name, true) (creates an immutable binding in TDZ state)
  3. Execution reaches declaration line: evaluate initialization expression, call InitializeBinding(name, value) (TDZ ends)
  4. Subsequent assignment: call SetMutableBinding(name, newValue, true) → detects immutable → TypeError

💎 Level 4 · Edge Cases and Traps

Trap 1: Duplicate var Declarations Are Silent; Last Assignment Wins

function example() {
  var x = 1;
  var x = 2;  // No error! Duplicate declaration is ignored; only assignment takes effect
  var x;      // No error, no effect (binding already exists; not re-initialized)
  console.log(x); // 2
}

// A more insidious version
function confusing() {
  console.log(x); // undefined (var hoisting)

  for (var i = 0; i < 3; i++) {
    var x = i;    // This var x is only "effective" on the first pass (subsequent are assignments)
  }

  console.log(x); // 2 (i was 2 on the last iteration)
  console.log(i); // 3 (i is still accessible after the loop)
}

Specification behavior: during the function instantiation phase, the VarDeclaredNames algorithm creates only one binding for duplicate var declarations. At runtime, when var x = value is encountered, only the assignment part executes—the binding is neither recreated nor reset to undefined.

Trap 2: let's TDZ Shadows an Outer Variable of the Same Name

This scenario was introduced in Level 1; here we examine its engineering impact in depth:

const DEBUG = true; // outer global variable

function configure(options) {
  if (options.debug) {
    // You might intend to re-declare DEBUG as a local variable here
    console.log(DEBUG); // ReferenceError! The inner let's TDZ shadows the outer const DEBUG
    let DEBUG = options.debug;
    console.log(DEBUG); // only accessible from here on
  }
}

This trap is especially easy to trigger during refactoring: if an inner let declaration uses the same name as an outer variable, TDZ will silently turn what should be "access the outer scope" into a ReferenceError at runtime.

How to spot it: during code review, be wary of any reference to a name that appears before a let/const declaration of the same name—it is not "the outer scope's value"; it is a TDZ.

Trap 3: typeof Is Not Safe on TDZ Variables

Developers sometimes use typeof x !== 'undefined' to safely check whether a variable exists. This works for undeclared variables but explodes for TDZ variables:

// Safe check for an undeclared variable (works)
if (typeof jQuery !== 'undefined') {
  // jQuery is available
}

// But this fails
function checkFeature() {
  if (typeof myFeature !== 'undefined') { // ReferenceError! myFeature is in TDZ
    console.log(myFeature);
  }
  let myFeature = getFeature(); // TDZ ends here
}

Specification reason: typeof's exemption only applies to Reference Records with [[Base]] = unresolvable (completely undeclared variables). A TDZ variable's Reference Record has a real ER as [[Base]] (not unresolvable), so it does not benefit from the exemption; GetValue proceeds and throws ReferenceError.

Practice: never use typeof to detect a variable of the same name as a let/const declared later in the same scope. Treat typeof safety checks as "for detecting variables from the external environment only (browser globals, third-party scripts)."

Trap 4: The Classic for...in + var Last-Value Trap

const funcs = [];

// Wrong version: j is shared via var
for (var j in { a: 1, b: 2, c: 3 }) {
  funcs.push(() => console.log(j));
}
funcs.forEach(f => f()); // prints 'c' three times (the last key)

// Correct version: let k creates a new binding each iteration
for (let k in { a: 1, b: 2, c: 3 }) {
  funcs.push(() => console.log(k)); // each closure captures a different k
}
// Note: key order in for...in is not guaranteed by spec!
// V8 sorts integer keys ascending; string keys follow insertion order

for...in and for...of with let work the same way as a regular for loop with let: a new ER is created each iteration. However, for...in/for...of do not have an "update expression copy" step—the iteration variable is initialized with the new value at the start of each iteration, rather than being copied from the previous iteration's ER.

Trap 5: const and Destructuring / Function Parameter Interactions

// const with destructuring
const { x, y } = point; // both x and y are const bindings
// x = 1; // TypeError

// TDZ inside function parameter default values
// Parameters are initialized left to right;
// later parameters' default values can reference earlier parameters
function greet(name, greeting = `Hello, ${name}`) {
  console.log(greeting);
}
greet('Alice'); // "Hello, Alice"

// But a parameter cannot reference itself in its own default (TDZ)
function broken(x = x) { // ReferenceError: x is in TDZ when it references itself
  return x;
}
broken(); // ReferenceError

Function parameters have their own EnvironmentRecord; each parameter is initialized left to right. When processing x = x, the binding has been created (TDZ state) but not yet initialized. The default value expression tries to read x from TDZ state, triggering ReferenceError.


Summary

  1. const is the default declaration form in modern JavaScript: the binding cannot be reassigned, but the contents of an object value can be modified. Object.freeze only freezes one level of properties; deep freezing requires recursion. let permits reassignment while maintaining block scope; var is reserved for maintaining legacy code.

  2. let/const bindings are created immediately when a block is entered (in uninitialized state, i.e., TDZ), and are fully initialized only when execution reaches the declaration statement. The TDZ spans the entire code region from the start of the block to the declaration statement, and the typeof operator also throws ReferenceError within this region.

  3. A for (let i ...) loop uses the CreatePerIterationEnvironment algorithm to create a new DeclarativeER for each iteration, copying the current iteration variable's value into it. This is the specification mechanism that gives let-based for loops independent bindings for each closure.

  4. An inner let/const TDZ shadows an outer variable of the same name: GetIdentifierReference stops searching outward once it finds an uninitialized binding in the current ER; calling GetValue on it throws ReferenceError, making the outer variable of the same name completely inaccessible.

  5. A global var declaration attaches to the global object (window.x === 1) via the ObjectRecord inside GlobalEnvironmentRecord, while global let/const lives in the DeclarativeRecord inside GlobalEnvironmentRecord and does not attach to the global object (window.x === undefined).

Rate this chapter
4.7  / 5  (13 ratings)

💬 Comments