Tags: web-dev concept

Scope and Hoisting

Date: 2026-09-28


Before any line of a scope runs, the engine creates every binding declared in it. “Hoisting” is just that creation step being visible — and the only real question is what each kind of declaration is initialised to before its line is reached.


Scope is the region of code in which a binding is visible; hoisting is the observable effect of the engine creating all of a scope’s bindings on entry, before executing any of its statements.

Two phases per scope

ENTER SCOPE                                  RUN STATEMENTS TOP TO BOTTOM
create every binding declared here     →     initialise let/const/class when
  var          → initialised: undefined        their declaration line runs
  function     → initialised: the function
  let / const  → created, UNINITIALISED  ← reading it here throws
  class        → created, UNINITIALISED
DeclarationScopeOn entryRedeclareReassign
varfunctionundefinedyesyes
functionfunction (block, in strict mode)the functionyesyes
letblockuninitialisednoyes
constblockuninitialisednono — but the object is still mutable
classblockuninitialisednoyes

Nothing moves. “Hoisting” suggests declarations are lifted to the top of the file; they aren’t. The binding exists from the start of the scope, and the declaration line only initialises it.

The temporal dead zone

The temporal dead zone (TDZ) is the stretch of time between entering a scope and running a let/const/class declaration, during which the binding exists but any access throws a ReferenceError. It’s temporal, not positional:

function report() {
  return total;           // refers to the outer `total` binding
}
 
// ── TDZ for total starts here (scope entered) ──
// report();              // ✗ ReferenceError: Cannot access 'total' before initialization
let total = 42;
// ── TDZ ends: declaration line executed ──
report();                 // ✓ 42 — code *above* the line is fine if it RUNS after it
time ──────────────────────────────────────────────────►
     enter scope          let total = 42          report()
         │◄──── TDZ: access throws ────►│   accessible

Why the TDZ exists: with var, reading too early silently gives undefined, and the bug surfaces three functions later. The TDZ turns it into an immediate, named error. It also makes const coherent — a constant that is visibly undefined and later something else would not be constant.

typeof undeclaredName returns "undefined", but typeof on a binding in its TDZ throws — the one place the dead zone surprises people who thought typeof was always safe.

Lexical scope

Scope is decided by where code is written, not where it’s called from. A function looks names up through the environment it was defined in — the mechanism behind Closures. The one exception in the language is this, which is decided at the call site — this and Binding.

Top-level scope depends on how the file is loaded:

  • Classic script — top-level var and function declarations become properties of window; let/const don’t, but are still shared across every classic script on the page
  • Module — every file has its own scope; nothing leaks to window — Modules

Where it goes wrong

  • var in loops and blocks — one function-wide binding where a per-iteration one was meant. Shown in Closures
  • Circular imports — a module reads an imported const before the exporting module has run its declaration: TDZ ReferenceError at load time, often only in one import order
  • Class used before its declaration — class declarations aren’t initialised on entry, unlike function declarations, so new Foo() above class Foo throws
  • Implicit globals — assigning to an undeclared name creates a global in sloppy mode and throws in strict mode. Modules and class bodies are always strict
  • Shadowing — an inner let data hides the outer one for the whole block, including lines above it inside that block, which then throw rather than reading the outer value