JavaScript has three keywords for declaring a variable. Older code is full
of var; modern code uses let and const. They look interchangeable,
but they behave differently in ways that cause real bugs — and the usual
explanation of why, "declarations are moved to the top of the scope", is
wrong in a way that stops being harmless the moment you hit "Cannot access
'x' before initialization".
This post covers the practical differences first, then what's actually going on underneath.
var belongs to the whole function (or the whole script). let and const belong to the nearest { } block. Inside the block both exist.
Scope: where a variable exists
A variable's scope is the part of the code where it can be used.
let and const are block-scoped: they exist only inside the nearest
pair of curly braces { } — an if, a loop, a function body.
if (true) {
let message = 'hi';
console.log(message); // 'hi'
}
console.log(message); // ReferenceError: message is not defined
var is function-scoped: it ignores blocks and belongs to the whole
function it's declared in (or the whole script, outside any function).
if (true) {
var message = 'hi';
}
console.log(message); // 'hi' — it leaked out of the block
Block scope is what you'd expect from almost any other language, and it keeps variables close to where they're used.
The loop bug
This is the most famous var problem:
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i));
}
// 3, 3, 3
There's only one i for the whole loop. The timers run after the loop has
finished, when that single i is 3 — so all three print 3.
Switch to let, and each iteration gets its own i:
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i));
}
// 0, 1, 2
let in a for head creates a new binding for each iteration and copies
the value forward, so each callback remembers the i from its own
iteration — a closure doing
exactly what you'd want.
const: can't reassign, can still change
const means the variable can't be pointed at a different value:
const max = 10;
max = 20; // TypeError: Assignment to constant variable.
But it does not make the value itself unchangeable. If it holds an object or array, you can still modify what's inside:
const user = { name: 'Ada' };
user.name = 'Grace'; // fine — same object, changed contents
user = {}; // TypeError — can't point user at a new object
const list = [1, 2];
list.push(3); // fine
const protects the variable, not the data. To freeze the data, there's
Object.freeze — but you
rarely need it.
Using a variable too early
With var, reading a variable before its declaration line gives
undefined instead of an error:
console.log(count); // undefined
var count = 5;
With let and const, it's an error — which is better, because using a
variable before you've set it is almost always a mistake:
console.log(total); // ReferenceError: Cannot access 'total' before initialization
let total = 5;
Why the difference? That's hoisting.
Hoisting: two phases, not code moving
Nothing moves to the top of the file. The engine processes a scope in two phases: before a single statement executes, it scans the scope and creates a binding for every declaration it finds. Then it runs the code. The declarations differ in what state their binding starts in:
creation phase
scope bindings
scanning…
Before a single line runs, the engine scans the scope and creates every binding it declares. "Hoisting" is not code moving upwards — it is bindings existing before execution starts.
Four kinds of declaration, four starting states:
| Declaration | Binding created | Initial state | Access before the line |
|---|---|---|---|
| function f() {} | Yes | The whole function | Works |
| var x | Yes | undefined | Returns undefined |
| let x | Yes | Uninitialized (TDZ) | ReferenceError |
| const x | Yes | Uninitialized (TDZ) | ReferenceError |
| class C {} | Yes | Uninitialized (TDZ) | ReferenceError |
Every one of them is hoisted — the binding exists before the line runs. The difference is only whether it holds something usable yet.
The error message is the clue
These two errors mean different things, and the wording distinguishes them:
console.log(nope);
// ReferenceError: nope is not defined
// → no binding anywhere. Typo, or a missing import.
console.log(later);
let later = 1;
// ReferenceError: Cannot access 'later' before initialization
// → the binding exists. You are in its temporal dead zone.
The second message is telling you the engine knows about later — it
found the let during the scan — and is refusing to let you read it yet.
The dead zone is a region of code, not a period of time
Despite the name, "temporal" is a little misleading. The TDZ is the stretch of code from the top of the scope to the initializing statement. Whether that stretch executes in a microsecond or a minute doesn't matter:
function demo() {
// TDZ for `value` starts here
const read = () => value; // fine to *define* — nothing runs yet
// read(); // would throw: still in the TDZ
let value = 42; // TDZ ends
return read(); // 42
}
Closing over a TDZ variable is legal. Only reading it before initialization throws — which is exactly the behaviour that makes mutual recursion between functions work.
Why the TDZ exists
It's not gratuitous strictness. It makes const honest and catches a real
class of bug.
With var, reading too early gives undefined, and undefined flows
silently through your program until it fails somewhere else entirely:
function totalPrice(items) {
const total = items.reduce((sum, i) => sum + i.price * taxRate, 0);
var taxRate = 1.2; // declared below — undefined during the reduce
return total; // NaN, blamed on `items` in the bug report
}
The same code with let throws at the exact line that's wrong, with the
variable's name in the message.
The TDZ is also what allows const to mean never unassigned. If const
bindings started as undefined like var, there would be a window where a
constant held a value it was never assigned — and the engine would have to
allow writing to a const at least once, undermining the guarantee.
Function declarations vs function expressions
Only declarations are fully hoisted:
hoisted(); // works
function hoisted() {}
notHoisted(); // TypeError: notHoisted is not a function
var notHoisted = function () {};
alsoNot(); // ReferenceError: Cannot access 'alsoNot' before initialization
const alsoNot = () => {};
Three different failures for three declaration styles, and each error names
its cause: the var binding exists and holds undefined (so calling it is a
type error), while the const binding is in its TDZ (a reference error).
One more sharp edge: a function declaration inside a block is block-scoped in
strict mode and ES modules, but has messy legacy behaviour in sloppy scripts.
Declare functions at the top level of a function or module, not inside if
blocks.
Side by side
| var | let | const | |
|---|---|---|---|
| Scope | Function | Block | Block |
| Can reassign | Yes | Yes | No |
| Can redeclare in same scope | Yes | No | No |
| Hoisted binding starts as | undefined | Uninitialized (TDZ) | Uninitialized (TDZ) |
| Use before declaration | undefined | ReferenceError | ReferenceError |
| New copy per loop iteration | No | Yes | Yes |
The rule
- Use
constby default. - Use
letwhen you genuinely need to reassign — a counter, a value that changes in a loop. - Don't use
varin new code. This isn't style preference —letandconstturn a silentundefinedinto an immediate, named error.
Starting from const has a nice side effect: when you see let, it's a
signal to the reader that this value is going to change, so it's worth
watching. Declare variables close to where they're used — the TDZ only
bites code that reads a variable above its declaration, which is worth not
writing anyway. And read the error message carefully: "is not defined" and
"cannot access before initialization" point at completely different
mistakes.