JavaScript Variables and Scope: let, const, var, Hoisting, and the TDZ
Variable declaration looks like the most boring topic in any language. In JavaScript it isnโt โ because the language carries two generations of scoping rules side by side (var from 1995, let/const from 2015), and because scope is the foundation that closures, modules, and half of all interview questions are built on.
This guide gives you the modern rules, the legacy rules you must recognize, hoisting as it actually works (not the folklore version), and the classic loop bug that demonstrates why any of this matters.
The Modern Rules First: const by Default
Modern JavaScript has a simple declaration policy:
const apiUrl = "https://api.example.com"; // default choice: cannot be reassignedlet retries = 0; // only when reassignment is genuinely neededretries += 1; // fine
apiUrl = "other"; // TypeError: Assignment to constant variableUse const unless you will reassign; then use let. Never write var in new code. This isnโt style dogma โ each keyword makes a promise to the reader. const says โthis name means one thing for its whole lifeโ; let says โwatch this one, it changes.โ Codebases where 90%+ of declarations are const (which is typical once you write modern JS) are dramatically easier to skim, because mutation โ the source of most confusion โ is flagged where it happens.
One crucial nuance, the same one Javaโs final has: const freezes the binding, not the value.
const user = { name: "Ada" };user.name = "Grace"; // fine โ the object is still mutableuser = { name: "Alan" }; // TypeError โ the *binding* can't be repointed
const list = [1, 2, 3];list.push(4); // fineconst means โthis variable will always point at the same thing,โ not โthis thing canโt change.โ For actual immutability you need Object.freeze (shallow) or, more practically, the discipline of not mutating โ a theme the objects guide develops.
Scope: Where a Name Exists
JavaScript has three kinds of scope for let/const:
const global = "visible everywhere in this file/script"; // module or global scope
function process() { const fnScoped = "visible throughout process()"; // function scope
if (true) { const blockScoped = "visible only inside these braces"; // block scope } // blockScoped does not exist here โ ReferenceError if accessed}Block scope โ any { } pair creates one: if bodies, loop bodies, even a bare { } โ is the 2015 upgrade. Names live exactly as long as the logic needs them, no longer. Inner scopes see outer names; outer scopes never see inner names. When scopes nest, lookup walks outward โ the scope chain:
const a = "outer";function f() { const b = "middle"; function g() { const c = "inner"; console.log(a, b, c); // finds c locally, b one level up, a two levels up } g();}Two facts about the chain worth engraving:
- Itโs determined by where functions are written, not where theyโre called (lexical scoping). You can see what
gcan access by reading the source โ no runtime detective work. This predictability is what makes closures possible:gdoesnโt just seeb, it retains access to it even ifgis passed elsewhere and called afterfreturns. - Shadowing is legal and occasionally a trap: an inner
const xhides an outerxfor the inner scopeโs duration. Linters flag suspicious shadowing for good reason โ it turns โwhich x is this?โ into a real question.
var: The Legacy You Must Recognize
var predates block scope and behaves as if blocks donโt exist โ itโs function-scoped only:
function demo() { if (true) { var leaky = "I escape the block"; let contained = "I don't"; } console.log(leaky); // "I escape the block" โ var ignored the braces console.log(contained); // ReferenceError}var also re-declares silently (var x = 1; var x = 2; โ no error, a typo-hider), and at the top level attaches itself to the global object. Each behavior is a bug class that let/const eliminated. Youโll still see var constantly โ in legacy code, old Stack Overflow answers, and AI-generated code trained on both โ so the skill isnโt using it; itโs recognizing its scoping rules on sight while writing const yourself.
Hoisting, Told Straight
The folklore version โ โdeclarations get moved to the topโ โ causes more confusion than it resolves. Hereโs the accurate model:
Before executing a scope, the engine registers every declaration in it. All declared names exist from the scopeโs start. The difference between keywords is what state the name is in before its declaration line runs:
console.log(a); // undefined โ var: registered AND initialized to undefinedvar a = 1;
console.log(b); // ReferenceError โ let: registered but UNINITIALIZEDlet b = 2; // (the "temporal dead zone" until this line)
hello(); // "hi!" โ function declarations: fully hoisted, body and allfunction hello() { console.log("hi!"); }
greet(); // TypeError โ it's a const in its TDZโฆ and even afterconst greet = () => console.log("hey"); // initialization happens at this lineUnpacking:
varhoists with a value (undefined) โ so early access silently โworks,โ yieldingundefinedand deferring the explosion to wherever thatundefinedgets used. Classic fail-soft JS.let/consthoist without a value. The stretch between scope start and the declaration line is the temporal dead zone (TDZ) โ access throws immediately, loudly, at the actual mistake. The TDZ isnโt a gotcha; itโs the fix for varโs gotcha.- Function declarations (
function f() {}) hoist completely โ calling before the definition line works, which genuinely helps: you can order a file top-down, main logic first, helpers below. - Function expressions and arrows assigned to
const/letfollow variable rules, not function rules โ theyโre only callable after the assignment line.
Practical distillation: declare before use, and hoisting mostly becomes trivia โ except for reading legacy var code, where the โsilent undefinedโ behavior explains many a mystery bug.
The Loop Bug: Where Scope Stops Being Abstract
The single most famous demonstration of why let exists:
// with var โ the 1995 behaviorfor (var i = 0; i < 3; i++) { setTimeout(() => console.log(i), 10);}// prints: 3, 3, 3
// with let โ the 2015 fixfor (let i = 0; i < 3; i++) { setTimeout(() => console.log(i), 10);}// prints: 0, 1, 2Why var prints 3, 3, 3: there is exactly one i for the whole loop (function-scoped, remember). The three arrow functions all close over that one variable. By the time the timers fire (after the loop finishes โ see the event loop), i is 3, and all three callbacks read the same final value.
Why let fixes it: the spec gives each loop iteration its own fresh i binding, copied from the previous iterationโs ending value. Three iterations, three separate variables, three closures each capturing their own. This per-iteration binding was designed specifically because the var version bit so many people for so many years.
The bug generalizes beyond timers: any callback created in a loop โ event handlers, promise chains, array of functions โ captured the shared var. If you ever maintain pre-2015 code, youโll meet the historical workaround (an immediately-invoked function creating a scope per iteration) and can now read it for what it is: hand-rolled block scope.
Global Scope: The Place to Stay Out Of
Names at the top level of a script are global โ shared by every script on the page, a collision festival. Two modern mitigations:
leaked = "oops"; // assignment without any declaration creates a global (!)First, strict mode makes that undeclared assignment an error instead of a silent global. You get strict mode automatically inside ES modules and classes โ one more reason modules are the default.
Second, modules changed the game: top-level declarations in a module are scoped to that module, not the planet. The modern unit of code organization is the module file, with explicit export/import โ global variables have gone from โhow you share codeโ to โcode smell with occasional legitimate usesโ (feature detection, genuinely app-wide config).
Related habit: keep declarations close to use and scopes small. A const declared 80 lines before its one usage forces the reader to hold it in their head for 80 lines. Block scope lets you declare inside the if/loop where the name actually lives โ use that.
Frequently Asked Questions
Why prefer const even for things I never intended to reassign anyway? Because the reader doesnโt know your intentions โ the keyword documents them, and the engine enforces them. An accidental reassignment becomes a TypeError at the typo, not a corrupted state three functions later. Zero cost, permanent small dividend.
Is let in a for loop really creating a new variable every iteration โ isnโt that slow? The semantics are per-iteration bindings; engines optimize it heavily, and no real application has ever been bottlenecked by it. Correctness first; V8 has your back here.
What about function declarations inside blocks? Historically inconsistent across browsers, later standardized with subtle rules โ the practical guidance is simply: donโt. Use const f = () => {} inside blocks; reserve function declarations for the top level of modules/functions.
Does const make objects thread-safe like Javaโs final-plus-immutability? JSโs main thread is single-threaded, so the concurrency angle mostly doesnโt apply โ but the reasoning benefits are identical: bindings that canโt change are facts you can rely on while reading. For actual deep immutability, freeze or (better) treat objects as immutable by convention with spread-based updates โ covered in objects & spread.
How do I share a variable between two functions? Declare it in their common enclosing scope โ thatโs the scope chain working for you. If the functions live in different files, you want module exports, not globals.
Reading Legacy Scope: A Translation Table
Since youโll read a decade of pre-2015 code (and AI output trained on it), a quick translation guide from old idioms to their modern intent:
| You see | It meant | Modern form |
|---|---|---|
var self = this; | capture this for callbacks | arrow function |
(function(){ ... })(); | create a private scope | block {} or a module |
(function(i){ ... })(i) in a loop | per-iteration capture | let in the loop head |
var x = x || {}; | default/namespace guard | const x = imported ?? {} / modules |
"use strict"; at file top | opt into strict mode | automatic in modules & classes |
Each row is a workaround for a missing feature, now shipped. Recognizing them as historical scaffolding โ not style choices to imitate โ is what lets you read old answers on Stack Overflow without importing 2012 into your codebase.
Self-Check
Predict each, then verify in a console:
// 1console.log(x);var x = 5;
// 2console.log(y);let y = 5;
// 3const arr = [1, 2];arr.push(3);console.log(arr.length);
// 4for (var i = 0; i < 2; i++) {}console.log(i);
// 5for (let j = 0; j < 2; j++) {}console.log(j);
// 6const f = () => "a";function g() { return "b"; }// which of these could be called ABOVE its definition line?Answers: (1) undefined โ var hoists with undefined. (2) ReferenceError โ TDZ. (3) 3 โ const binding, mutable array. (4) 2 โ var leaks out of the loop. (5) ReferenceError โ let is block-scoped to the loop. (6) only g โ function declarations hoist fully; the arrow assigned to const sits in the TDZ.
Six for six? Then youโre ready for the payoff chapter: these scoping rules are exactly the machinery behind closures โ but first, Data Types and Coercion, where we tame ==, NaN, and the type systemโs famous surprises.