Modules
Prerequisites assumed: closures, lexical scope, import/export syntax, and the browser/Node execution model. This chapter covers how ES modules actually load, evaluate, and link — and where they differ from CommonJS.
Static Structure
ES modules are static: their imports and exports are resolved before any code runs. The parser walks the file, collects import declarations, and builds a dependency graph before evaluation. This is why import must be at the top level — you cannot import conditionally or from inside a function with the static syntax.
// logger.js
export function log(msg) {
console.log(`[${new Date().toISOString()}] ${msg}`);
}
export default class Logger { /* ... */ }
// app.js — bindings, not copies
import Logger, { log } from './logger.js';
log('started');The key distinction from CommonJS: import { log } creates a live binding to the exporting module's variable. If logger.js later reassigns an exported let, importers see the new value. In CommonJS, require() copies values at require time — objects are shared, primitives are not.
Evaluation Semantics
- Parse — the loader resolves all specifiers and fetches the graph.
- Instantiate — module records are built; exported bindings are linked before execution.
- Evaluate — module bodies run once, depth-first, in dependency order. The result is cached.
Because evaluation is cached per-URL, a module is a natural singleton. Cycles are allowed but risky: during evaluation of a cyclic import, bindings exist but are uninitialized, so accessing a const binding too early throws a ReferenceError (a temporal dead zone violation across module boundaries).
// a.js
import { b } from './b.js';
export const a = 'a';
console.log('a evaluates', b);
// b.js
import { a } from './a.js';
console.log('b evaluates', typeof a); // 'undefined' — TDZ if read before init
export const b = 'b';Dynamic Import
import() returns a promise and can appear anywhere, enabling code splitting:
button.addEventListener('click', async () => {
const { renderChart } = await import('./chart.js');
renderChart(data);
});This trades static analyzability for laziness. Bundlers can only split code they can see statically; fully dynamic specifiers (await import(someVariable)) defeat tree-shaking.
Interop and Gotchas
| Concern | ES Modules | CommonJS |
|---|---|---|
| Load time | Static, parse-time | Dynamic, run-time |
| Exported values | Live bindings | Copied at require time |
| `this` at top level | `undefined` | `module.exports` |
| Cycle behavior | TDZ risk, deterministic | Partially-filled `exports` object |
| Caching | Per URL, once | Per resolved path, once |
In Node, a file is an ES module only if "type": "module" is set in package.json or the extension is .mjs. The most common failure is mixing systems: require() cannot load an ES module synchronously, and top-level await only works in modules.
Show diagram source
flowchart TD
A[Parse entry module] --> B[Resolve all imports recursively]
B --> C[Instantiate: link bindings]
C --> D[Evaluate dependencies depth-first]
D --> E[Evaluate module body once]
E --> F[Cache result per URL]Exercise
Why does mutating an imported binding throw a SyntaxError, while mutating a property of an imported object succeeds?
Show answer
Import bindings are read-only references to the exporting module's variable — reassigning them is a static error. If the binding holds an object, the exporting module owns the reference, and nothing prevents any module from mutating that object's internals.
Exercise
In a cyclic import a.js → b.js → a.js, when does b.js see a as initialized?
Show answer
Only after a.js's body finishes executing the const a = 'a' declaration. If b.js reads a during a.js's evaluation but before that line runs, it hits the temporal dead zone and throws. Reorder initialization or move the shared value into a third module.
Spotted a mistake, or code that does not work?