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
| Declaration | Scope | On entry | Redeclare | Reassign |
|---|---|---|---|---|
var | function | undefined | yes | yes |
function | function (block, in strict mode) | the function | yes | yes |
let | block | uninitialised | no | yes |
const | block | uninitialised | no | no — but the object is still mutable |
class | block | uninitialised | no | yes |
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 ittime ──────────────────────────────────────────────────►
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
varand function declarations become properties ofwindow;let/constdon’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
varin loops and blocks — one function-wide binding where a per-iteration one was meant. Shown in Closures- Circular imports — a module reads an imported
constbefore the exporting module has run its declaration: TDZReferenceErrorat 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()aboveclass Foothrows - 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 datahides the outer one for the whole block, including lines above it inside that block, which then throw rather than reading the outer value