Scope answers the question "from where is this variable visible". In JavaScript it is lexical — determined by where the variable is written in the code, not by where someone tries to read it at runtime. It's a simple rule, but three historical storylines have piled up around it: var, hoisting, and the dead zone. Let's go through all of them.
Lexical nesting
Scopes nest like matryoshka dolls: a function sees its own variables, its parent function's variables, and globals — but not the other way around.
const outer = "outer";
function parent() {
const middle = "middle";
function child() {
const inner = "inner";
console.log(outer, middle, inner); // sees all three
}
child();
// console.log(inner); // ReferenceError — you can't peek inside
}
Name lookup goes from the inside out: first the own scope, then the parent's, and so on up to the global one. The first name found wins — an inner variable shadows an outer one with the same name.
var versus let/const: function versus block
var is the pre-war way of declaring, and its scope is the entire function, not the block:
function demo() {
if (true) {
var v = 1;
let l = 2;
}
console.log(v); // 1 — var "leaked" out of the block
console.log(l); // ReferenceError — let lives only in the block
}
let and const are block-scoped: they live inside the nearest {}. The only difference between them: const forbids reassignment. An important nuance: const does not make an object immutable — you can mutate its contents; you just can't repoint the reference:
const arr = [1, 2];
arr.push(3); // allowed — mutating contents
arr = []; // TypeError — can't repoint
The modern rule: const by default, let when reassignment is genuinely needed, var — never.
Hoisting: why code sees variables "before" their declaration
Before execution, the engine scans the scope and "learns" about all declarations in advance — this is called hoisting. But declarations are hoisted differently:
console.log(v); // undefined — var is hoisted and initialized to undefined
var v = 1;
console.log(l); // ReferenceError! — let is hoisted but NOT initialized
let l = 2;
hi(); // "hi" — a function declaration is hoisted IN ITS ENTIRETY
function hi() { console.log("hi"); }
bye(); // TypeError — only the const variable was hoisted, not the value
const bye = () => console.log("bye");
The zone from the start of the block to the let/const line is called the temporal dead zone (TDZ): the variable already "exists", but reading it is an error. This is deliberate design: better to fail with a ReferenceError than to silently get undefined, as with var.
The practical takeaway: a function declaration can be called before it's declared (handy to put a file's main function at the top and helpers at the bottom); arrow functions in a const — only after.
The classic: var in a loop
The legendary puzzle that caught generations of developers:
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 10);
}
// 3, 3, 3 — not 0, 1, 2!
Why: var i is one variable for the whole loop (function scope). All three callbacks closed over that single one, and by the time the timers fire the loop has finished and i === 3.
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 10);
}
// 0, 1, 2 — let creates a NEW variable for each iteration
This isn't timer magic — it's pure scope: let in the loop header means "a fresh variable on every turn". One line — two different worlds.
Modules and the global scope
Every ES module (a file with import/export) is its own scope: the file's top-level variables don't reach the global one. These days the global scope is polluted only by non-module scripts and assignments without declaration (in non-strict mode, x = 1 silently creates a global variable — yet another reason why modules and classes always run in strict mode).
In short
- Scope is lexical: what matters is where the variable is written. Name lookup goes from the inside out; an inner variable shadows an outer one.
varis function-scoped,let/constare block-scoped. The rule:constby default,letwhen needed,var— never.constforbids reassignment, but not mutating an object's contents.- Hoisting: a function declaration is available before its declaration in its entirety;
let/constbefore the declaration line — a dead zone (ReferenceError);vargives a silentundefined. varin a loop — one variable for all iterations,let— a new one for each. Hence3, 3, 3versus0, 1, 2.
What to read next
- Closures — what happens when a function takes its scope along with it.
- Type coercion — the previous topic in the series.
- Event loop — why the setTimeout in the example fired after the loop finished.