The language and the runtime
Scope, Hoisting, and the Temporal Dead Zone
How var, let and const differ in scope and initialisation, what hoisting really moves, and why the temporal dead zone is a feature rather than an inconvenience.
The first error most people meet in modern JavaScript is
ReferenceError: Cannot access 'total' before initialization, and it appears on a
line that looks correct: the let total = 0 is three lines further down the
function, and reading a name before its declaration worked yesterday with var.
The two behaviours are not versions of the same rule. var and
let have different scope, different initialisation and different failure modes,
and the error message only makes sense once you know which one you are looking at. The good
news is that the difference is small enough to hold in your head, and once it is there the
error stops being mysterious and becomes a location.
This lesson covers the three declarations, what hoisting actually moves, why closures are
the reason scope matters at all, and the loop bug that let was introduced to
fix.
Every binding lives in a scope, and closures are what make that matter
A scope is a region of the program where a name can be used. When a function runs, it gets a fresh scope for its parameters and its local declarations, and that scope has a chain of parents ending at the module or global scope. Name resolution walks that chain outward and stops at the first match.
A closure is what you get when a function keeps a reference to a scope after the function that created it has returned. The inner function does not copy the variable, it holds the binding itself:
function counter() {
let count = 0;
return function () {
count += 1;
return count;
};
}
const next = counter();
console.log(next(), next(), next()); // 1 2 3
console.log(typeof count); // undefined — the scope is not reachable from here
count is a single binding that both the increment and the read share. Call
counter() again and you get a different binding, independent of the first. This is
the whole mechanism behind private state, and it is also the reason the loop bug below is so
confusing: a closure captures the binding, so whatever the binding holds when the closure
finally runs is what the closure sees.
var is function scoped, and let and const are block scoped
The scope of a var is the nearest enclosing function, or the module if there is
no function. Braces do not stop it:
function demo() {
if (true) {
var leaks = "function scoped";
let stays = "block scoped";
}
console.log(leaks); // function scoped
console.log(typeof stays); // undefined
}
leaks is visible after the if block because the block is not a
scope for var. Reading stays after the block is not an error, it is a
name that does not exist there, which is why typeof reports
"undefined" rather than throwing.
let and const are scoped to the nearest enclosing block, and a
block includes the body of an if, a for loop, or a bare
{ }. That is the entire difference in scope, and it matters mostly in loops. A
loop that logs progress and then reports the final count is the case to watch:
function withVar(names) {
for (var i = 0; i < names.length; i += 1) {
// ... work
}
return i; // names.length, readable after the loop
} // (only when names.length is at least 1)
function withLet(names) {
for (let i = 0; i < names.length; i += 1) {
// ... work
}
return i; // ReferenceError: i is not defined
}
Neither behaviour is wrong. The var version leaks a counter into the function,
which is occasionally useful and usually accidental. Reach for let when the
counter is meant to stay inside the loop, which is almost always.
const prevents rebinding and nothing else
const is not "immutable". It means the binding cannot be reassigned. The value
it points at is untouched by the guarantee:
const config = { retries: 3 };
config.retries = 5;
console.log(config.retries); // 5 — the object was mutated, the binding was not
config = {}; // TypeError: Assignment to constant variable.
You also have to initialise it. const total; on its own is a
SyntaxError: Missing initializer in const declaration, and it is a parse-time
error, so the file never runs. That catches the mistake early and I consider it a point in
const's favour.
Redeclaration rules differ too. var allows the same name twice in the same
scope, and the second declaration is ignored as a declaration while its assignment still
happens. let and const reject a second declaration in the same scope
with SyntaxError: Identifier 'b' has already been declared. That error is also
raised before any code runs, which is why the tooling can catch it without executing the
file.
var | let | const | |
|---|---|---|---|
| Scope | function or module | block | block |
| Before its line | undefined | throws | throws |
| Reassignable | yes | yes | no |
| Redeclarable in the same scope | yes | no | no |
| Initialiser required | no | no | yes |
| Global object property at top level of a script | yes | no | no |
The last row is the one that surprises people migrating old browser code. In a classic
<script> file, var total = 1 creates
window.total and let total = 1 does not. In a Node module the file is
already wrapped in a function, so neither creates a global property and the row is moot. I
mention it because the difference shows up when a script is moved from a browser page into a
module.
Hoisting moves declarations, not initialisations
Before a function or a module body runs, the runtime walks it and registers every declaration. That registration is what hoisting means, and what happens next depends on the kind of declaration.
A var is registered and immediately initialised to undefined. The
assignment stays where you wrote it. That is why this prints undefined undefined
instead of throwing:
console.log(typeof hoistedVar, hoistedVar); // undefined undefined
var hoistedVar = 1;
A function declaration is registered together with its body, so it is callable before its
line. This is not a trick to rely on; it does mean that mutual recursion between two functions
at the top of a file works regardless of order. A function expression assigned to a
var gets no such treatment, because the assignment has not run yet:
console.log(declared()); // works
function declared() { return "callable before its line"; }
console.log(assigned); // undefined — the var is hoisted, the function is not
var assigned = function () { return "not yet"; };
A let or const is registered in its scope but left
uninitialised. The name exists, reading it throws, and the interval between the start of the
scope and the initialising line has a name: the temporal dead zone.
The temporal dead zone is the gap where a binding exists but has no value
The dead zone runs from the top of the enclosing block to the declaration. Inside it, any
read throws ReferenceError: Cannot access 'x' before initialization. This applies
to typeof as well, which is exactly the exception that does not exist for an
undeclared name:
try {
console.log(typeof blocked);
} catch (error) {
console.log(error.name + ": " + error.message);
// ReferenceError: Cannot access 'blocked' before initialization
}
let blocked = 1;
console.log(typeof neverDeclared); // undefined — no binding anywhere, so no throw
That pair is worth remembering because it tells you which problem you have. Throwing means
the binding exists and you are early. Returning undefined means there is no
binding at all and you have a typo or a missing import.
The dead zone is a deliberate design choice, not an accident of implementation. With
var, a use-before-declaration silently reads undefined and the
program continues with a wrong value. With let, the same mistake stops at the
line that made it. I would rather see the error than debug the symptom two modules later.
One consequence is easy to miss. The dead zone is per block, so a shadowed name is in its own dead zone inside the inner block even when an outer binding with the same name is initialised:
const total = 10;
function read() {
try {
console.log(total); // throws: this block's `total` is not initialised yet
} catch (error) {
console.log(error.name);
}
const total = 20;
return total;
}
console.log(read()); // ReferenceError then 20
let fixed the loop capture bug because each iteration gets its own binding
This is the bug that pushed let into the language. A loop that creates
functions and calls them later returns the same value from every function, because
var creates one binding for the whole loop:
function varLoop() {
const out = [];
for (var i = 0; i < 3; i += 1) {
out.push(function () { return i; });
}
return out.map(function (fn) { return fn(); });
}
function letLoop() {
const out = [];
for (let i = 0; i < 3; i += 1) {
out.push(function () { return i; });
}
return out.map(function (fn) { return fn(); });
}
console.log(varLoop()); // [ 3, 3, 3 ]
console.log(letLoop()); // [ 0, 1, 2 ]
The var version returns three threes because all three functions captured the
same binding, and by the time the functions run the loop has finished and i is
3. The counter is not off by one here; the value is correct for the loop, and the
closures are all looking at it.
The let version returns [0, 1, 2] because the specification gives
each iteration of the loop its own binding, created by copying the previous iteration's value
before the update step runs. That is why the fix is a one-word change and why the fix is not
"wrap it in an IIFE", which was the workaround for years. If the copying is easier to hold
onto, write the equivalent by hand:
const perIteration = [];
for (let i = 0; i < 3; i += 1) {
const copy = i;
perIteration.push(() => copy);
}
console.log(perIteration.map((fn) => fn())); // [ 0, 1, 2 ]
Note what happens to a var declared inside the loop body rather than used as
the loop variable. There is still one function-scoped binding, so the captured value is
whatever the last assignment left behind. Switching the loop variable to let
does not fix that; using const for the per-iteration value does.
My default for new code is const, let when the binding has to
change, and var never. The one place I still write var is inside a
generated file I do not control. Everything else is avoidable, and the linter rules that
enforce this are on by default in most modern setups.
Both functions below are wrong, and they are wrong for different reasons. Run the file, write down what each one prints or throws before you run it, then fix each one with the smallest change that preserves the intended output.
function firstPass(names) {
const lines = [];
lines.push("counter starts at " + counter);
var counter = names.length;
lines.push("counter is now " + counter);
const printers = [];
for (var j = 0; j < 2; j += 1) {
printers.push(function () { return "printer " + j; });
}
return { lines: lines, printers: printers };
}
function secondPass(names) {
const lines = [];
lines.push("counter starts at " + counter);
let counter = names.length;
return lines;
}
The intended behaviour is that firstPass reports the number of names and
that its printers identify themselves as printer 0 and
printer 1. The intended behaviour of secondPass is the same
report, without the throw.
Then answer: why does fixing firstPass require two separate changes, and
what does secondPass tell you about the difference between a name that is
absent and a name that is early?
Show the solution and the reasoning
Here is the file as written, with both calls made so you can see each failure in isolation.
const report = firstPass(["ana", "bo"]);
console.log(report.lines.join("\n"));
console.log(report.printers.map(function (fn) { return fn(); }));
try {
secondPass(["ana", "bo"]);
} catch (error) {
console.log(error.name + ": " + error.message);
}
counter starts at undefined
counter is now 2
[ 'printer 2', 'printer 2' ]
ReferenceError: Cannot access 'counter' before initialization
firstPass fails quietly twice. The first line prints
undefined because var counter is registered and initialised to
undefined before the function body runs, so the read succeeds with the wrong
value. The two printers both report printer 2 because they captured the single
function-scoped j, and by the time they were called the loop had already exited
with j at 2.
The smallest correct version uses const for the counter and the loop variable
for the printers, and computes the value before the line that uses it:
function firstPass(names) {
const lines = [];
const counter = names.length;
lines.push("counter starts at " + counter);
lines.push("counter is now " + counter);
const printers = [];
for (let j = 0; j < 2; j += 1) {
printers.push(function () { return "printer " + j; });
}
return { lines: lines, printers: printers };
}
Two changes because there are two separate bugs that happen to share a cause. Moving the
declaration above the use removes the read-before-assignment. Changing var to
let on the loop variable gives each iteration its own binding. You could fix the
second one alone and the printers would be right while the first line stayed
undefined; you could fix the first alone and get the counter right with three
printers all saying printer 2. The shared cause is that var gave
both bindings function scope and an undefined initial value. The shared fix is
to stop using it.
const for the counter rather than let is a judgement call, not a
requirement. Nothing reassigns it, so const says that out loud and stops the
next edit from changing it by accident.
secondPass is the contrast that answers the second question. The name
counter is not absent, it is early: let registered the binding at
the start of the block and left it uninitialised, so the read throws
ReferenceError: Cannot access 'counter' before initialization. An absent name
gives the error counter is not defined instead, and
typeof absentName gives "undefined" without throwing. Learning to
read the two messages apart tells you immediately whether to look for a typo or for an
ordering mistake. Fix secondPass by moving the declaration above the first
lines.push; the dead zone ends at the declaration, so the second line is fine
either way.
Progress
Mark this lesson done and it stays marked in this browser.