JavaScript Module Hoisting and Temporal Dead Zones
JavaScript module declarations are processed before module code executes, but lexical bindings such as `let`, `const`, and `class` are not usable until their initialization step. This creates temporal dead zones (TDZs), including cases involving imported and exported bindings.
Module code is evaluated in a defined dependency and initialization process rather than simply running top to bottom as one script. During module instantiation, bindings for declarations and imports are established before the module's executable statements run. This is why an imported name can be referenced by code even though the corresponding declaration appears elsewhere in the dependency graph.
Hoisting does not mean that a lexical variable receives its value early. `let`, `const`, and `class` bindings exist before their declaration is evaluated, but accessing them before initialization throws a `ReferenceError`. The interval between binding creation and initialization is the temporal dead zone.
ES modules use live bindings. An `import` does not create an independent copy of an exported variable; it provides access to the exporting module's binding. Consequently, an imported binding can reflect later changes made to the original exported binding, while direct assignment to the imported name is not allowed.
Module cycles make these rules especially important. A cyclic dependency can cause one module to access another module's lexical binding before that binding has been initialized. In such a case, the problem is not that the import itself failed; evaluation reached the binding while it was still in its TDZ.
Key points
- Module bindings are established during module instantiation before module evaluation runs.
- `let`, `const`, and `class` bindings can exist before initialization but remain inaccessible during their TDZ.
- Imports are live bindings to exports rather than copied values.
- A cyclic dependency can expose an imported lexical binding before its initialization and cause a `ReferenceError`.
- Hoisting describes declaration processing; it does not imply that a lexical binding already has its final value.
Interview questions
Are imported variables copies of the exported values?
No. ES module imports are live bindings associated with the exporting module. If the exporter changes a mutable exported binding, observers of that binding can see the updated value. The importing module cannot directly assign to the imported binding itself.
What does hoisting mean for `let` and `const` in JavaScript modules?
Their bindings are created before the module's executable statements run, but they are not initialized immediately. Accessing a binding before its initialization reaches the temporal dead zone and throws a `ReferenceError`. Therefore, saying that `let` and `const` are hoisted does not mean they can safely be read before their declaration executes.
Why can a module import be used even though its source declaration appears in another file?
The module system first instantiates the dependency graph and establishes the relevant bindings before evaluating module bodies. The importing module therefore has a binding corresponding to the export before its own evaluation proceeds. The imported name is a live reference to the exporting module's binding, not a value copied at import time.
What happens with a circular dependency between two ES modules?
A cycle is represented in the module dependency graph and does not by itself make the modules invalid. The important issue is evaluation order and whether a module accesses a dependency's binding before that binding has been initialized. If such an access reaches an uninitialized lexical binding, it can throw a `ReferenceError` because the binding is still in its TDZ.
How would you diagnose a `ReferenceError` caused by a circular module dependency?
First, identify the cycle in the module dependency graph and determine which binding is being accessed. Then trace the evaluation order and locate whether the access occurs before the exporting module initializes that binding. Refactoring can remove the early read, move shared initialization to a separate module, or change the dependency structure so the required binding is initialized before use.