Hoisting in JavaScript - what the metaphor really means

The textbook explanation says declarations 'move to the top' of the code. The ECMA-262 spec doesn't define hoisting as a mechanism at all, and let behaves as if the rule didn't apply to it. So what really happens before your code runs, and why does the TDZ look exactly the way it does?
Most people first encounter hoisting like this: variable and function declarations are moved to the top of the code before anything runs. For functions, that rule explains why something like this just works:
greet("Anna");
function greet(name) {
console.log(`Hi, ${name}!`);
}You call greet before defining it, and the engine doesn’t complain. If the function is available earlier, someone must have lifted it up there. Hence the word hoisting - from hoist, to lift. Let’s stick with it and see how far the metaphor takes us.
For variables, the same theory says it slightly differently: only the declaration moves to the top, not the value assignment. That’s why this also runs without errors:
console.log(value); // undefined
var value = 10;According to the theory, the name value is already registered at the start of the scope, so console.log(value) has something to reference - the engine sees that the name exists. The value 10 stays at its line and only lands in the variable when the engine gets there. Between those two points value is undefined - which matches what we observe. Theory holds.
It works fine until you try the same with let:
console.log(value);
let value = 10;By the same theory you should get undefined - just like with var. Instead, the engine throws ReferenceError: Cannot access 'value' before initialization. Something doesn’t add up. Time to see what’s actually happening - because it’s not code being moved around.
#The engine runs your code in two passes
Before the engine runs the first line, it has to read the whole file. Those are two separate stages. I covered them earlier in the post about execution context - here, only one detail matters: names are known to the engine before any line of code runs.
In the first stage - the compilation phase - the engine walks through every scope and takes inventory: what declarations are here, what names will be visible, what they will point to. It builds a structure in memory called an Environment Record - a list of identifiers available in that scope. The spec calls this step GlobalDeclarationInstantiation. All of it happens before any line of your code runs.
In the second stage - execution - the engine runs through the code line by line. When it hits a reference to a name, it checks the Environment Record of the current scope (and walks up the parent chain) to see whether such a name exists, and if so, what’s stored there.
Hoisting is the colloquial name for the fact that names are registered before execution. Nothing physically moves. Your source file stays exactly as you wrote it - the engine just split its work into two passes and knew all the names before the first line ran.
#Three declarations, three behaviors
This is where it gets more interesting - because not every declaration is registered the same way. What you end up with in the Environment Record for function, var and let after the compilation phase is three different things.
Function declarations (function name() {}) are fully created after compilation. In the Environment Record, the name already points to a complete function object, with body and all. That’s why calling a function before its textual declaration in the file just works - to the engine, the function existed before the first line ran.
var declarations (var name = ...) are hoisted, but only as the name itself - the identifier exists in the Environment Record, and its value is undefined. The actual value lands there only in the execution phase, when the engine reaches the line with the assignment.
That explains a subtlety worth remembering. A function declaration is hoisted with the whole body. A function expression assigned to var is not. The name is reserved, sure, but the value is undefined until the engine runs the assignment line.
let declarations (and const) are hoisted, but in the uninitialized state. The name exists in the Environment Record, but you can’t reach it - reading throws ReferenceError. This is the Temporal Dead Zone (TDZ). The uninitialized state ends only when the engine runs the declaration line in the execution phase.
The quickest way to see all of this side by side:
What you're often taught
Popular intuition: the engine first scans the file and lifts all declarations to the top of their scope. Functions, var, let, const — all together.
1console.log(addOne(5));2console.log(addTwo(5));3console.log(triple);4 5function addOne(x) {6 return x + 1;7}8var addTwo = function (x) {9 return x + 2;10};11let triple = 3;↑ everything jumps to the top
Sounds convincing — and it partly explains why you can call a function before defining it. But it doesn't explain why let throws ReferenceError. Because that's not how the engine works.
Three lines at the top of the file trigger three different situations:
addOne(5)works, becauseaddOneis a function declaration - after compilation it’s complete in memory.addTwo(5)throwsTypeError, becauseaddTwois a function expression assigned tovar. The Environment Record holdsundefined, and callingundefinedas a function is a TypeError.triplethrowsReferenceError, becauselet tripleis registered in the compilation phase as uninitialized, and the engine won’t let you reach uninitialized names.
All three are “hoisted” - in the sense that their names exist in the Environment Record before the first line of code. But they behave in three different ways, because hoisting isn’t a single operation. It’s a shorthand for “name registered in the compilation phase”, not for “value ready right away”.
#Why the TDZ exists
The TDZ - that period when let and const exist but throw ReferenceError - sometimes gets called a quirk of the standard. But if you trace what I just showed, the TDZ falls out naturally from two design decisions.
First decision: let and const are hoisted like other declarations. In other words - the engine has to know these names from the start of the scope, before the first line in the block runs, not only when execution reaches the let or const line. That simplifies the whole scope analysis - everything visible inside a given block is known the moment the block starts. The same mechanism applies to class - class declarations are also registered as uninitialized until their line in the code.
Second decision: let and const won’t pretend to be var. var gives you undefined before assignment - meaning code written incorrectly goes through silently. The ES2015 standard wanted to cut off that silent pass. If a name is registered but the value isn’t there yet - the engine doesn’t return undefined. It returns an error.
In other words: the TDZ is the difference between “the name doesn’t exist” and “the name exists, but its value hasn’t been assigned yet”. var collapses both into one - both return undefined. let/const deliberately keep them apart.
Mechanically, it looks like this: in the spec, §9.1.1.1.6 GetBindingValue spells out the step: if you read a name that hasn’t been initialized yet, throw ReferenceError. This isn’t a special path for the TDZ - it’s the default behavior for uninitialized entries in an Environment Record. The TDZ is just a name for the period during which this condition is true for let/const/class.
#Hoisting is about when, not what
Back to the main thread. Hoisting isn’t an operation on your code. Your file isn’t reorganized, declarations don’t jump to the top, nothing physically moves. The engine splits its work into two passes - compilation and execution - and the only thing “hoisting” really describes, in shorthand, is that names are registered in the compilation phase, before your first line of code runs.
It all boils down to two practical consequences:
- Functions declared with
functionare available everywhere in their scope, including the lines before the textual declaration. That’s because after compilation, the name already points to a complete function object. var,let,constall have reserved names, but different starting states -varholdsundefined,let/constare uninitialized (reading =ReferenceError). The uninitialized state ends only when execution reaches the declaration line.
Hence the practical rule you’ve probably adopted anyway, if you’ve written modern JS: declare before you use. Not because hoisting is “bad” - just because let/const won’t let you do otherwise, and var will, but you get undefined where you usually wanted an error.
Back to what we started with: declarations don’t move to the top of the code. The engine just takes inventory of your names before running anything - and from that single decision come three different behaviors: ready-to-go functions, var with undefined, TDZ for let and const. Three effects, one mechanism. Hoisting doesn’t change your code - it’s simply the name for the moment when the engine starts to know about it.
Comments
Loading comments…