Hoisting and the TDZ
Mental model: Every scope is set up before it runs, but let and const are set up without a value.
Level: intermediate · about 11 minutes
sayHi(); // 'hi', the function already exists
function sayHi() {
console.log('hi');
}
console.log(later); // undefined, not an error
var later = 'assigned now';
console.log(later); // 'assigned now'Calling a function above its own definition. This works, and it is not magic.
Before a scope runs a single line, the engine walks it and registers every declaration it finds. That pass is the creation phase. Running the statements in order is the execution phase. Hoisting is not code being physically moved, it is declarations being registered early.
- Creation phase: register the names Function declarations are stored complete.
varbindings are created and set toundefined.letandconstbindings are created but deliberately left uninitialised. - Execution phase: run the lines Assignments happen now, in source order. Reaching a
letorconstline is what finally initialises that binding. - Touch a
lettoo early and it throws The gap between "registered" and "initialised" is the temporal dead zone.
| Declaration | Registered early? | Value before its line | Reading it early |
|---|---|---|---|
function f() {} | yes, fully | the function | works |
var x | yes | undefined | works, and usually hides a bug |
let x | yes | nothing, it is in the TDZ | ReferenceError |
const x | yes | nothing, it is in the TDZ | ReferenceError |
class C {} | yes | nothing, it is in the TDZ | ReferenceError |
var f = function () {} | the var only | undefined | TypeError: f is not a function |
The temporal dead zone
The TDZ is the stretch of a block between the start of the scope and the line where a let or const is initialised. The binding exists, so the name is taken, but touching it throws. It is temporal, not spatial: what matters is when the code runs, not where it sits on the page.
console.log(typeof neverDeclared); // 'undefined', no error
try {
console.log(typeof pending); // same operator, different outcome
} catch (err) {
console.log(err.name); // 'ReferenceError'
}
let pending = 1;typeof is safe for an undeclared name, and unsafe inside the TDZ.
Functions: declaration versus expression
Declaration: usable early
greet(); // works
function greet() {
console.log('hello');
}
Expression: only the var is early
greet(); // TypeError:
// greet is not a function
var greet = function () {
console.log('hello');
};In the second case greet exists and holds undefined at call time, so the error is a TypeError about calling a non-function, not a ReferenceError about a missing name. The error type tells you which mistake you made.
console.log(typeof f, typeof g);
function f() {}
var g = function () {};f is a function declaration, so it is fully available during the creation phase. g is a var holding a function expression: the binding exists and is undefined until the assignment runs, so typeof g is "undefined".
Try it yourself
TDZ explorer
function demo() {
console.log('1. var before assignment:', hoistedVar);
try {
console.log('2. let before declaration:', tdzLet);
} catch (err) {
console.log('2. let before declaration:', err.name);
}
var hoistedVar = 'assigned';
let tdzLet = 'assigned';
console.log('3. both are fine now:', hoistedVar, tdzLet);
}
demo();
Move the let declaration above the try block. Then turn it into a var and compare the two failures.
Check yourself
- What does this log?
- ReferenceError —
countis registered when the scope is created but stays uninitialised until its own line runs. Reading it in between throws aReferenceError, and thecatchlogs the name. Withvar countyou would have seenundefinedinstead. - What does hoisting actually describe?
- Declarations are registered when the scope is created, before any statement runs — Nothing moves. The scope is built first, which is why some names already exist on line 1. What differs between the keywords is the value each binding starts with: a function,
undefined, or nothing at all. - You use
typeof xbefore alet xdeclaration in the same block. What happens? - It throws a
ReferenceError—typeofprotects you only from names that were never declared at all. Aletin its temporal dead zone is declared, so the operator has to touch the binding, and touching it throws.
Common mistakes
- Describing hoisting as code moving upward. Nothing moves; declarations are just registered first.
- Trusting
typeofas a safety net. It throws for aletorconstinside the TDZ. - Calling a function stored in a
varbefore the assignment line and misreading theTypeErroras a missing function.
Takeaways
- Every scope has a creation phase (register names) and an execution phase (run lines).
- Function declarations arrive complete;
vararrives asundefined;letandconstarrive uninitialised. - The TDZ is the window before a
letorconstis initialised, and reading it throws. - The error type is a clue:
ReferenceErrormeans the binding is unusable,TypeErrormeans the value is wrong.