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:
- Parse phase:
constdeclaration detected; recorded as a LexicallyScopedDeclaration - On block entry: call
CreateImmutableBinding(name, true)(creates an immutable binding in TDZ state) - Execution reaches declaration line: evaluate initialization expression, call
InitializeBinding(name, value)(TDZ ends) - 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
-
constis the default declaration form in modern JavaScript: the binding cannot be reassigned, but the contents of an object value can be modified.Object.freezeonly freezes one level of properties; deep freezing requires recursion.letpermits reassignment while maintaining block scope;varis reserved for maintaining legacy code. -
let/constbindings are created immediately when a block is entered (inuninitializedstate, 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 thetypeofoperator also throwsReferenceErrorwithin this region. -
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 giveslet-based for loops independent bindings for each closure. -
An inner
let/constTDZ shadows an outer variable of the same name:GetIdentifierReferencestops searching outward once it finds an uninitialized binding in the current ER; calling GetValue on it throwsReferenceError, making the outer variable of the same name completely inaccessible. -
A global
vardeclaration attaches to the global object (window.x === 1) via the ObjectRecord inside GlobalEnvironmentRecord, while globallet/constlives in the DeclarativeRecord inside GlobalEnvironmentRecord and does not attach to the global object (window.x === undefined).