Variables, let, const, and Scope
Assumed prerequisites
This chapter assumes you already know what a variable is, have read the chapter on values and types, and are comfortable reading short JavaScript snippets. We are working at the depth of the specification: lexical environments, the temporal dead zone, hoisting semantics, and the shadowing and module-scope interactions that cause real bugs in production code.
The execution model: lexical environments
JavaScript resolves every variable reference through a chain of lexical environments. An environment is a record mapping names to bindings, created each time a function runs, a block executes, a module loads, or a script starts. When code reads an identifier, the engine looks it up in the current environment, then the enclosing one, and so on up to the global environment. This is why scope is lexical — determined by where the code is written, not how it is called.
letandconstcreate declarative bindings: mutable and immutable respectively, block-scoped, uninitialized until their declaration executes.varcreates bindings on the enclosing function (or global) environment, initialized toundefinedimmediately.- At the top level of a module (or a classic script),
let/const/classgo into the global lexical environment — a separate layer from the global object — whilevarand function declarations become properties of the global object. This is whylet x = 1at top level does not createwindow.x.
// Module scope vs global object
let lexical = 'in global lexical environment';
var globalProp = 'on globalThis';
console.log(globalThis.globalProp); // 'on globalThis'
console.log(globalThis.lexical); // undefinedThree declaration forms, three behaviors
This part of the chapter cannot be displayed here.
Two clarifications that are commonly muddled:
constmakes the binding immutable, not the value. Objects and arrays declared withconstcan still be mutated; you cannot reassign the name.- Redeclaration of
letin the same lexical scope is a static error, but the same name can legally appear in sibling scopes and in nested scopes (shadowing).
const config = { host: 'localhost' };
config.host = 'prod.internal'; // fine — mutating the object
// config = {}; // TypeError: assignment to constant variable
function shadowing() {
let x = 'inner';
{
let x = 'shadowed';
console.log(x); // 'shadowed'
}
console.log(x); // 'inner'
}Hoisting and the temporal dead zone
All declarations are hoisted in the sense that the engine knows about them before executing the containing block. The difference is when the binding is initialized:
varbindings are initialized toundefinedat function entry. Reading them early yieldsundefined.let/constbindings are created at block entry but remain uninitialized until the declaration statement runs. Reading them before that is aReferenceError. This window is the temporal dead zone (TDZ).functiondeclarations are fully hoisted: initialized and usable at the top of the scope.
console.log(a); // undefined — var is initialized at entry
var a = 1;
console.log(b); // ReferenceError — TDZ
let b = 2;
{
console.log(c); // ReferenceError, even though c exists in this block
let c = 3;
}The second snippet is worth dwelling on: inside the block, the engine finds the let c binding and its TDZ status before reaching the outer scope. TDZ errors are therefore "binding found but not ready," not "binding not found." This lets tools and lint rules catch shadowing-related footguns.
Getting parameters into the TDZ picture
Function parameters are initialized before the function body runs, but default parameter expressions are evaluated in an inner scope and can themselves trigger TDZ errors:
function f(a = b, b = 2) {} // ReferenceError when called with no args`var` in the modern codebase
var has one legal-but-poorly-understood behavior left that matters in reviews: it ignores block scope, which is exactly what made the classic loop-closure bug possible.
// With var: every closure captures the same binding
const fs = [];
for (var i = 0; i < 3; i++) fs.push(() => i);
console.log(fs.map((f) => f())); // [3, 3, 3]
// With let: a fresh binding per iteration
const gs = [];
for (let i = 0; i < 3; i++) gs.push(() => i);
console.log(gs.map((f) => f())); // [0, 1, 2]Mechanically, for (let ...) creates a new environment per iteration and copies the previous value into it before the update expression runs. That per-iteration copy is specified behavior, not luck.
Decision criteria
- Default to
constfor every binding you do not reassign. It makes data flow explicit: the reader knows the name never changes meaning. - Use
letonly for genuine reassignment: loop counters, accumulators, swap patterns. - Avoid
varexcept when maintaining legacy code you cannot convert; lint rules (no-var) enforce this. - Consider that
const+ mutation is often a smell;Object.freezegives shallow immutability when you want it (nested objects still need freezing recursively).
callout note The const-by-default habit is not about preventing runtime errors — reassigning an object's binding is rarely what breaks code. It is about documentation: every const is a promise to the reader that this name is stable, which makes refactorings and dependency analysis cheaper. callout
callout warning TDZ errors throw a ReferenceError at runtime, and only when the read actually executes. A TDZ violation inside a branch that never runs is invisible. Linters catch the static cases, but do not assume a passing test proves TDZ safety. callout
Exercises
Exercise
In a classic script (non-module), let x = 1; and var x = 2; both at top level — what happens, and why?
Show answer
A SyntaxError. var x creates a property on the global object, while let x creates a binding in the global lexical environment, but the spec still treats redeclaring the same name across these layers as a conflict; var x = 2; let x = 1; fails the same way. (The reverse-order example, let x = 1; var x = 2;, throws at the point the var is processed — also a SyntaxError.)
Exercise
Predict the output of the classic loop-closure bug with var, and explain precisely why let fixes it.
Show answer
With var i, all three closures capture the same function-scope binding, which ends at 3, so output is [3, 3, 3]. With let i, the loop spec creates a fresh environment per iteration and copies the prior value into it, so each closure sees its own 0, 1, 2.
Exercise
Why does console.log(c) inside the block shown above throw a ReferenceError even though c is visible later in the same block?
Show answer
The block has its own declarative environment containing the let c binding, but that binding is uninitialized (TDZ) until the declaration statement executes. Lookup finds the binding and throws rather than falling through to any outer c.
Spotted a mistake, or code that does not work?