Programming Languages · Language Design
Declaration Instantiation, Hoisting, and the Temporal Dead Zone
How a scope is populated before its body runs, why var and function declarations are visible early while let and const are not, and what the dead zone prevents.
A scope is not populated as its statements execute. Before the body runs, the implementation walks the declarations it contains and creates bindings for them, so that a name can be resolved from anywhere in the scope. That pre-pass is what people call hoisting, and the differences between declaration kinds are differences in what the pre-pass does: whether it initialises the binding, whether it creates it at all, and whether reading it before initialisation is an error or a value.
Two passes over a body
Consider a function whose body references a name declared later:
function total() {
return subtotal() + tax;
}
function subtotal() { return 100; }
var tax = 8;
This works because the function declaration subtotal and the variable tax are both instantiated before the body of total runs. The implementation does not read the source top to bottom and stop at the first reference. It creates an environment for the scope, binds every declaration in it, and then executes statements.
The observable consequences split by declaration kind. A function declaration is instantiated with its body already attached, so calling it before its textual position works. A var is instantiated with the value undefined, so reading it before the assignment yields undefined rather than an error. A let or const is instantiated but left uninitialised, and reading it before the initialiser runs throws.
That third case is the temporal dead zone. The binding exists from the start of the scope, so the name resolves to it and cannot be captured from an outer scope, but the binding has no value until its declaration is evaluated. The zone is the interval between the binding’s creation and its initialisation.
Why the dead zone is a feature
The obvious reading is that the dead zone is an implementation artifact that produces a confusing error. The stronger reading is that it fixes a real defect in the var design.
With var, a reference before the declaration silently sees undefined. That makes a typo indistinguishable from a legitimate use, and it makes the following pattern compile and run:
function configure(options) {
if (options.debug) {
level = 'verbose';
}
var level = 'quiet';
return level;
}
The assignment writes to the hoisted level, then the declaration re-initialises it to 'quiet', and the function returns 'quiet' regardless of the option. No error is reported. The bug is a scoping bug that the language declined to detect.
With let, the same function throws when the assignment executes, because level is in its dead zone. The throw is a runtime error at the point of the mistake, and it converts a silent wrong answer into a failure. That is the argument for the dead zone: it makes a class of error visible without requiring a static analysis.
The zone is also what makes block scoping sound. If a let inside a block were visible in the enclosing scope before its declaration, the name would shadow an outer binding for part of the block and not the rest, and the resolution rule would depend on position. Creating the binding at block entry and initialising it at the declaration keeps the shadowing rule uniform: the inner name shadows from the start of the block to its end.
Function declarations in blocks
Function declarations complicate the model because their instantiation rules differ between the top level of a function and the interior of a block. At function scope, a function declaration is instantiated with its body, and duplicate declarations of the same name are permitted, so a later declaration in the source is the one that survives instantiation. Inside a block, the declaration is block-scoped, and the specification carries an additional compatibility rule that gives the sloppy-mode form a function-scoped binding as well, assigned when the block’s declaration is evaluated.
The carve-out produces a surprising result: in sloppy mode, a function declaration inside a block also creates a binding in the enclosing function scope, and that binding is assigned only when the block’s declaration is evaluated, so code that runs before the block reaches the name as undefined rather than as an error. The precise rule is specified, but it is one of the few places where the scoping model has a compatibility exception rather than a uniform rule. Code that depends on the exception behaves differently under strict mode, which is a reason to avoid the construct.
Environments and the cost of resolution
The mechanism behind all of this is an environment: a record mapping names to bindings, with a pointer to an enclosing environment. Resolving a name walks the chain from the innermost environment outward and takes the first binding found. A binding is a slot that may be uninitialised, initialised, or mutable.
Walking a chain is O(depth) per lookup, and depth is bounded by nesting. Implementations do not walk at runtime for most code, because the resolution can be done statically: a name’s binding site is determined by the source, so the compiler can replace the lookup with a direct slot index or a captured variable reference. The environment chain is the specification’s model, and the implementation is free to flatten it.
The flattening is where closures get their cost. A variable that is captured by an inner function cannot live in a stack slot that disappears when the outer function returns, so it is allocated in a heap-allocated environment or a context object. Whether a variable is captured is a static property, so the compiler can decide at compile time which variables need heap allocation. The number of such variables, not the number of scopes, determines the allocation.
That is also why the dead zone need not cost anything at runtime. The specification requires an error only when a read actually executes before the initialisation, so a compiler that can prove the initialisation precedes every read removes the check; where it cannot, the check is a comparison against an uninitialised sentinel or a static flag. The zone is a specification property, not an obligation to emit a test at every access.
How other languages instantiate scopes
Python’s rule is simpler at the surface and different underneath. A name bound anywhere in a function is local to that function for its entire body, so a reference before the assignment raises UnboundLocalError rather than resolving to an outer binding. The decision is made at compile time from the presence of a binding anywhere in the function, which is the same pre-pass idea with a coarser granularity: there are no block-scoped locals in the C sense, so a name is local or global, not block-local.
The consequence is a well-known confusion:
count = 0
def bump():
count = count + 1
The count = makes count local to bump, so the right-hand side reads an unbound local and raises. The fix is a global or nonlocal declaration, which tells the compiler that the name resolves outward. The rule is uniform and the error is immediate, which is the same trade the dead zone makes: fail loudly rather than read an outer value by accident.
Scheme’s internal definitions use a different mechanism again. A body’s definitions are converted into an equivalent letrec* expression: the names are all in scope in every initialiser and in the body, and the initialisers run in textual order. It is an error if an initialiser refers to the value of its own variable or of a variable defined later, so mutual recursion is legal and a forward reference to a value is not. The letrec* formulation is the cleanest statement of the idea: the scope is created first, the bindings are added, and the initialisers are evaluated in that scope.
C and C++ take the static route with no runtime check. A block-scoped variable’s lifetime begins at its declaration, and using it earlier is a compile error because the name is not in scope. There is no dead zone because there is no early binding: the declaration’s point is the start of the scope. The cost is that a declaration must appear before its use, which forces some restructuring in code that would prefer to declare at the top of a block.
The design axes
Three independent decisions determine a language’s behaviour here. The first is whether a scope’s bindings are created at entry or at the declaration. Entry creation enables mutual recursion and forward references, and requires a rule for the uninitialised state. Declaration-time creation forbids forward references and needs no such rule.
The second is whether a read of an uninitialised binding is an error or a default value. The error is safer and requires the dead zone; the default value is what var chose and what made the silent-shadowing bug possible.
The third is the granularity of the scope: function, block, or module. Finer granularity reduces the lifetime of bindings and the set of captured variables, at the cost of more environments and more opportunities for shadowing.
Evaluation Order, Sequencing Points, and Unspecified Behaviour is the same question about expressions rather than declarations: which order the language guarantees, and what it leaves open. The dead zone is the declaration-level analogue of a sequencing rule, and the reason both exist is that an unspecified order plus a side effect produces a program whose meaning depends on the implementation.
The Dangling Else and How Grammars Resolve Ambiguity shows the same pattern in the grammar: a name or a token that could belong to two constructs is assigned to one by a rule outside the production. Here the rule is that the binding is created at scope entry, so the inner declaration wins for the whole block, and the dead zone is the price of making that rule total.
References
- ECMAScript Language Specification — Environment Records and Declaration Instantiation — Ecma International
- The Python Language Reference — Execution model, naming and binding — Python Software Foundation
- Revised Report on the Algorithmic Language Scheme — letrec and internal definitions — R7RS, Scheme Steering Committee