Hoisting and the temporal dead zone
What is really moved to the top of a scope, why let and const are hoisted too, and the difference between undefined and a ReferenceError.
Updated
Nothing actually moves
"Hoisting" describes an outcome, not a mechanism. Before running a scope, the engine creates every binding the scope declares. Only then does it execute the statements. Declarations therefore appear to have been lifted to the top, because they were registered before the first line ran.
What differs between var, let/const, and functions is what the binding holds in the meantime.
var — created and initialised to undefined
console.log(x); // undefined — not an error
var x = 5;
console.log(x); // 5The binding exists from the first line, holding undefined. The assignment happens where it is written. This is why var bugs are quiet: you read a value, get undefined, and carry on into a failure three lines later.
var is also function-scoped, not block-scoped:
if (true) { var leaked = 'yes'; }
console.log(leaked); // 'yes' — the block never contained itlet and const — created but not initialised
They are hoisted as well. The difference is that the binding starts uninitialised, and touching it before the declaration line throws:
console.log(y); // ReferenceError: Cannot access 'y' before initialization
let y = 5;That gap — from the start of the block to the declaration — is the temporal dead zone. "Temporal" is the right word: it is not a region of the file, it is a period of time. A function defined above the declaration can read y perfectly well, as long as it is *called* after it:
const read = () => y; // fine to define
let y = 5;
read(); // 5 — called after initialisationThe error message is worth reading closely. Cannot access 'y' before initialization means the binding exists and is not ready. y is not defined means there is no binding at all. Two different bugs, two different messages.
Function declarations — fully hoisted
greet(); // works
function greet() { return 'hi'; }The whole function is available before its line. This is the only form that is genuinely usable before it appears.
A function *expression* is not:
greet(); // TypeError: greet is not a function
var greet = function () {}; // the binding holds undefined
greet2(); // ReferenceError — TDZ
const greet2 = () => {};Same shape, three different failures, depending only on the keyword.
The summary table
| Declaration | Hoisted | Value before the line | Scope |
|---|---|---|---|
var | yes | undefined | function |
let | yes | TDZ → ReferenceError | block |
const | yes | TDZ → ReferenceError | block |
function | yes | the function | block (strict) |
class | yes | TDZ → ReferenceError | block |
class in the last row surprises people: classes are hoisted like let, so a class cannot be used above its declaration even though a function can.
Why this still matters
Modern code uses const by default and let where reassignment is real, which makes most of this invisible — that is the point of the TDZ. It turned a category of silent undefined bugs into loud errors at the exact line that caused them.
It stays worth knowing for two reasons. Interview questions are still written on it, because it separates people who have read the language from people who have only used it. And you will read old code, where a var in a loop or a block is not doing what the indentation suggests.
Now practice it
Reading this page is the cheap half. These are the exercises that make you use it.
- Quizeasy
Output & Coercion Traps · Output Basics
Predict-the-output snippets, which is precisely where the undefined/ReferenceError split shows.
- Quizeasy
Fundamentals · Core Concepts
Declarations, scope and the basics this page rests on.
- Quizmedium
Output & Coercion Traps · Tricky Outputs
The harder traps, where hoisting combines with closures and loops.