Modules
This chapter assumes you are comfortable with functions, closures, scope, and the event loop. We cover ES modules (ESM): their syntax, their static structure, how they differ from CommonJS (CJS), how cycles resolve, and the failure modes you will hit in real codebases.
Why modules exist
Before ES2015, JavaScript had no built-in module system. Code was shared through globals, IIFEs, and loaders like AMD or the eventual de facto standard, CommonJS in Node. ESM was added to the language itself, which changed what tools can do with your code: because imports are resolved statically, before the program runs, bundlers, minifiers, and type checkers can see the whole dependency graph without executing it.
Syntax and semantics
// math.js — a module's exports
export const PI = 3.14159;
export function area(r) { return PI * r * r; }
export default function describe() { return 'circle math'; }// app.js — importing
import describe, { PI, area as circleArea } from './math.js';
console.log(circleArea(2), PI, describe());Key properties:
- Each file is a module with its own top-level scope. Top-level
constin a module does not become a global. - Modules are parsed, dependencies resolved, then evaluated once per graph. Repeated imports of the same module share a single instance.
- Module code runs in strict mode automatically.
- Declarations are hoisted, but evaluation order follows the dependency graph: dependencies evaluate before their importers (depth-first, post-order).
Live bindings
This is the single most misunderstood ESM behavior. import binds a name to the exporting module's variable, not to a snapshot of its value. If the exporter reassigns that variable, importers see the change:
// counter.js
export let count = 0;
export function inc() { count += 1; }// main.js
import { count, inc } from './counter.js';
console.log(count); // 0
inc();
console.log(count); // 1 — the binding is liveYou cannot reassign an imported binding from the importing side (count = 5 throws in strict mode); only the exporting module can mutate it. Default exports of objects are the common workaround for mutable state, but note that export default is just a named binding called default with a live binding all the same.
ESM vs CommonJS
These are not interchangeable, and the differences cause real bugs:
| Aspect | ESM | CommonJS |
|---|---|---|
| Loading | Static; analyzed before execution | Dynamic; `require` runs at call time |
| Bindings | Live | Copied at require time (`module.exports`) |
| Cycles | Supported; hoisted bindings may be uninitialized | Returns a partial `exports` object |
| Top-level `this` | `undefined` | `module.exports` |
| File extension | `.mjs` or `"type": "module"` | `.js` by default |
Cycles
Circular imports are legal but dangerous. During a cycle, some module bodies have not finished evaluating, so their bindings may still be uninitialized (in the temporal dead zone):
// a.js
import { b } from './b.js';
export const a = 'a';
export function callB() { return b; } // fine: runs later
// console.log(b) here would throw ReferenceError// b.js
import { a, callB } from './a.js';
export const b = 'b';
console.log(callB()); // 'b'Failure mode: reading an imported binding at module top level in a cycle risks a ReferenceError: Cannot access 'X' before initialization. Functions are safe because they are only called later, after both modules evaluated. Decision rule: if a cycle exists, make sure cross-module reads happen inside functions, or better, break the cycle by extracting the shared code into a third module.
Dynamic import and import.meta
import() is not static; it returns a promise and can be called conditionally, enabling code splitting:
async function loadChart() {
const { render } = await import('./chart.js'); // fetched on demand
render();
}
if (needsChart) loadChart();import.meta carries module metadata. import.meta.url is the module's absolute URL (or file URL in Node); it is the standard way to resolve asset paths relative to a module instead of the working directory:
const url = new URL('./data.json', import.meta.url);
const res = await fetch(url);
console.log(await res.json());Bundlers, tree shaking, and gotchas
Because static imports cannot be computed at runtime, bundlers can tree shake: drop unused exports. This only works if your modules are side-effect-free. Two gotchas:
- Top-level side effects defeat shaking.
import './polyfill.js'must be kept, which is why packages declare"sideEffects": falseinpackage.json— an explicit contract, not something the tool can infer. - Re-export patterns matter. Prefer
export { x } from './x.js'over importing into an intermediate object; barrel files (index.jsre-exporting everything) can force bundlers to include whole subtrees.
Exercise
Exercise
In counter.js (ESM) you export let count = 0, and in main.js you write count = 10. What happens, and why does the same pattern in CommonJS behave differently?
Show answer
main.js throws a TypeError (assignment to a constant-like import binding in strict mode). Only the exporting module may reassign count. In CommonJS, require('./counter.js') returns a snapshot of module.exports at require time, so the importer's copy would be an independent value that it could freely mutate — silently diverging from the original.
Exercise
a.js imports config from b.js; b.js imports setup from a.js. a.js reads config.name at its top level and crashes with "Cannot access 'config' before initialization". Give two fixes and the trade-off.
Show answer
(1) Move the read into a function called after both modules evaluate — trade-off: the value is available only at call time, which may hide the timing dependency. (2) Extract the shared config into its own module imported by both, eliminating the cycle — trade-off: one more file, but the dependency graph becomes acyclic and easier to reason about.
Spotted a mistake, or code that does not work?