codejump
Academy

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);   // 5

The 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 it

let 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 initialisation

The 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

DeclarationHoistedValue before the lineScope
varyesundefinedfunction
letyesTDZ → ReferenceErrorblock
constyesTDZ → ReferenceErrorblock
functionyesthe functionblock (strict)
classyesTDZ → ReferenceErrorblock

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.