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.

  1. Creation phase: register the names Function declarations are stored complete. var bindings are created and set to undefined. let and const bindings are created but deliberately left uninitialised.
  2. Execution phase: run the lines Assignments happen now, in source order. Reaching a let or const line is what finally initialises that binding.
  3. Touch a let too early and it throws The gap between "registered" and "initialised" is the temporal dead zone.
DeclarationRegistered early?Value before its lineReading it early
function f() {}yes, fullythe functionworks
var xyesundefinedworks, and usually hides a bug
let xyesnothing, it is in the TDZReferenceError
const xyesnothing, it is in the TDZReferenceError
class C {}yesnothing, it is in the TDZReferenceError
var f = function () {}the var onlyundefinedTypeError: 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 — count is registered when the scope is created but stays uninitialised until its own line runs. Reading it in between throws a ReferenceError, and the catch logs the name. With var count you would have seen undefined instead.
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 x before a let x declaration in the same block. What happens?
It throws a ReferenceError — typeof protects you only from names that were never declared at all. A let in 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 typeof as a safety net. It throws for a let or const inside the TDZ.
  • Calling a function stored in a var before the assignment line and misreading the TypeError as a missing function.

Takeaways

  • Every scope has a creation phase (register names) and an execution phase (run lines).
  • Function declarations arrive complete; var arrives as undefined; let and const arrive uninitialised.
  • The TDZ is the window before a let or const is initialised, and reading it throws.
  • The error type is a clue: ReferenceError means the binding is unusable, TypeError means the value is wrong.