Hoisting and the Temporal Dead Zone
Hoisting Is Not What You Think
Most tutorials explain hoisting as "declarations are moved to the top of their scope." That's a useful simplification, but it's wrong. Nothing moves. The truth is about two separate phases of execution: the creation phase (where bindings are registered) and the execution phase (where code runs line by line).
Understanding the real mechanism explains every quirk — why var gives you undefined before its line, why let throws, and why function declarations work before their line but function expressions don't.
Imagine a teacher taking attendance before class starts. Before any lesson begins, the teacher walks through the roster and writes every student's name on the board. For var students, the teacher also writes "present but unprepared" (value: undefined). For let/const students, the teacher writes the name but marks them as "DO NOT CALL ON YET" (uninitialized — the TDZ). For function declaration students, the teacher writes their name AND their full assignment (the function body). When class starts, the teacher goes line by line. Calling on a var student before their line gives "unprepared." Calling on a let/const student before their line gets you thrown out (ReferenceError).
The Creation Phase
When a scope is entered (a function is called, a block is entered), JavaScript does the following before executing any code:
- Function declarations: Create the binding AND initialize it with the function object. Fully available immediately.
- var declarations: Create the binding and initialize it to
undefined. Available, butundefineduntil the assignment line runs. - let/const declarations: Create the binding but do NOT initialize it. Accessing it before initialization throws
ReferenceError. This is the Temporal Dead Zone.
console.log(a); // undefined (var — initialized to undefined)
console.log(b); // ReferenceError (let — in TDZ)
console.log(c()); // "works!" (function declaration — fully initialized)
var a = 1;
let b = 2;
function c() { return "works!"; }
The Temporal Dead Zone (TDZ)
The TDZ is not a physical location. It's a time window — the period between entering a scope and reaching the let/const declaration. During this window, the binding exists (it's been registered) but is uninitialized. Any access throws ReferenceError.
{
// TDZ for x starts here (entering the block)
console.log(x); // ReferenceError: Cannot access 'x' before initialization
let x = 5; // TDZ for x ends here
console.log(x); // 5
}
Why Does the TDZ Exist?
Without TDZ, let and const would behave like var — giving you undefined before initialization. This hides bugs. The TDZ makes accessing uninitialized variables a loud, immediate error instead of a silent undefined that causes problems downstream.
// Without TDZ (var behavior) — silent bug
function calculate() {
return price * quantity; // undefined * undefined = NaN — no error
var price = 10;
var quantity = 5;
}
// With TDZ (let behavior) — immediate error
function calculate() {
return price * quantity; // ReferenceError — bug caught
let price = 10;
let quantity = 5;
}
The typeof Exception — And When It Doesn't Apply
typeof has a special exception for undeclared variables — it returns "undefined" instead of throwing:
typeof undeclaredVariable; // "undefined" — no error
But this exception does NOT apply to TDZ variables:
{
typeof myLet; // ReferenceError!
let myLet = 5;
}
The difference: undeclaredVariable was never declared in any scope — typeof returns "undefined" as a safety mechanism for feature detection. But myLet WAS declared (by let) — it's in the TDZ. The binding exists; it's just uninitialized. JavaScript treats these as fundamentally different situations. This trips up developers who use typeof as a "safe" check.
Function Declarations vs Function Expressions
// Function declaration — hoisted completely
sayHello(); // "Hello!" — works before the line
function sayHello() {
return "Hello!";
}
// Function expression — only the var is hoisted
sayGoodbye(); // TypeError: sayGoodbye is not a function
var sayGoodbye = function() {
return "Goodbye!";
};
// sayGoodbye is undefined at the point of call —
// calling undefined() is a TypeError, not ReferenceError
// Arrow function with let — TDZ applies
greet(); // ReferenceError
const greet = () => "Hi!";
The key distinction: with var sayGoodbye = function() {}, only the var sayGoodbye part is hoisted (as undefined). The assignment happens at runtime. With function sayHello(), the entire function is available at creation time.
Conditional function declarations — the spec vs reality
Function declarations inside blocks (if, for, etc.) are technically not allowed in the spec's strict mode, and their behavior in sloppy mode is implementation-dependent:
// This behavior varies between engines in sloppy mode:
if (true) {
function test() { return "inside"; }
}
test(); // May or may not work depending on engine/mode
// In strict mode:
"use strict";
if (true) {
function test() { return "inside"; }
}
test(); // ReferenceError — block-scoped in strict modeIn sloppy mode, V8 hoists conditional function declarations to the function scope (for web compatibility). In strict mode, they're block-scoped like let. The safest approach: never put function declarations inside blocks. Use function expressions or arrows instead.
class Declarations and TDZ
class declarations behave like let — they have a TDZ:
const instance = new MyClass(); // ReferenceError
class MyClass {
constructor() {
this.value = 42;
}
}
This means classes must be defined before they're used. Unlike function declarations, class declarations are NOT fully hoisted.
Production Scenario: The Initialization Order Bug
// config.js
export const API_URL = `${BASE_URL}/api/v1`;
export const BASE_URL = "https://example.com";
// Bug: API_URL is initialized first, but BASE_URL is in the TDZ
// at that point. Result: ReferenceError
The fix — order declarations so dependencies come first:
// config.js
export const BASE_URL = "https://example.com";
export const API_URL = `${BASE_URL}/api/v1`;
This is a particularly nasty bug in module systems because the TDZ applies to module-level const in the order they appear.
| What developers do | What they should do |
|---|---|
| Saying 'declarations are moved to the top' The 'moving' metaphor fails to explain TDZ, function expressions, or conditional declarations | Nothing moves. Bindings are registered in the creation phase before code runs. |
| Using typeof to safely check let/const variables TDZ variables have a binding (they were declared). Undeclared variables have no binding at all. | typeof only protects against undeclared variables, not TDZ variables |
| Expecting function expressions (var f = function) to be callable before their line var f is hoisted as undefined. Calling undefined() is a TypeError. | Only function declarations are fully hoisted. Expressions hoist the var (as undefined), not the assignment. |
| Putting function declarations inside if/for blocks Block-level function declarations have inconsistent behavior across engines and modes | Use function expressions or arrows in conditional blocks |
- 1Nothing 'moves'. Bindings are registered in the creation phase: function declarations are fully initialized, var gets undefined, let/const remain uninitialized (TDZ).
- 2The TDZ is a time window, not a location — from scope entry to the declaration line.
- 3typeof undeclaredVar is safe (returns 'undefined'). typeof tdzVar throws ReferenceError.
- 4Function declarations hoist completely. Function expressions (var f = function) only hoist the var (as undefined).
- 5class declarations have TDZ behavior — they must be defined before use, unlike function declarations.