Skip to content

3.6.5 — Modules & Namespaces

A program of any size must be split across files — and how a language does that shapes everything from load performance to whether a bundler can shrink your app. JavaScript's module story is unusually instructive because it happened in public, twice: first the community invented modules out of closures (3.6.2) because the language had none; then Node.js and the standards committee each built a real system — CommonJS and ES Modules — whose differences still shape every package.json today. This page walks the whole lineage, then solves a genuinely puzzling everyday bug: why an imported value is sometimes undefined.

1. The global-scope catastrophe

Early JavaScript had no module system at all. Every <script> shared one global scope (3.6.1), which produced exactly the disaster you'd expect: two libraries defining a variable named config silently overwrote each other; load order mattered enormously; nothing was private. Every fix since is an answer to this.

The first-generation workaround added one global instead of dozens — a namespace, an ordinary object used as a container:

javascript
var MyApp = MyApp || {};              // create the single global, once
MyApp.utils = {
  formatDate(d) { /* … */ },
  parseId(s)   { /* … */ }
};
MyApp.utils.formatDate(new Date());   // everything reachable through one name

Collisions shrink to one name, and dots give hierarchy. But nothing is private — anyone can overwrite MyApp.utils.formatDate. What is a namespace and how is it used in practical JavaScript? [EQ-193]

2. The module pattern: closures doing architecture

Privacy arrived by combining the namespace idea with closures. Wrap code in a function that runs immediately (an IIFE), keep internals as its local variables, and return only the public interface:

javascript
const Counter = (function () {
  let count = 0;                       // (1) private — closure-captured, unreachable
  function log() { console.log(count); }        // (2) private helper

  return {                             // (3) the PUBLIC interface, chosen explicitly
    increment() { count++; log(); },
    value()     { return count; }
  };
})();                                  // (4) runs immediately; scope sealed forever

Counter.increment();   // → logs 1
Counter.count;         // → undefined — the real count is invisible

Everything not returned is genuinely private — encapsulation (3.5) built purely from 3.6.2's capture rules. The named variant, the revealing module pattern, defines everything as local functions and returns an object "revealing" chosen ones (return { increment, value }) so the public surface reads like a table of contents. Recognize these shapes on sight: every pre-2015 library (jQuery plugins, early Angular services) is built from them, and they still appear wherever code must run without a build step. What is the Module Pattern with namespace objects, or the Revealing Module Pattern? [EQ-46]

3. Two real module systems: CommonJS and ES Modules

JavaScript eventually got language-level modules — but twice, from different directions, and both are still in production everywhere.

CommonJS (CJS) came from Node.js (2009), before any standard existed. require() and module.exports:

javascript
// math.js
function add(a, b) { return a + b; }
module.exports = { add };            // export = assign to this object

// app.js
const { add } = require("./math");   // an ordinary function CALL

Its defining property: require() is synchronous and dynamic — it loads and executes the file at the moment the call runs, and being a normal function call it may sit inside an if, take a computed path, anything. Ideal for a server reading from local disk (2.6); impossible for a browser fetching over a network.

ES Modules (ESM) is the official standard (ES2015). import/export:

javascript
// math.js
export function add(a, b) { return a + b; }
export default class Calculator { /* … */ }    // one default export allowed

// app.js
import Calculator, { add } from "./math.js";   // default + named, one line

Its defining property is the opposite: import declarations are static — top level only, literal string paths — so the entire dependency graph is knowable before any code runs. That restriction is the whole point. It buys: (1) asynchronous loading — a browser can fetch the full graph in parallel over the network, which synchronous require could never do; (2) tree shaking — a bundler can prove an export is never imported anywhere and delete it from the output (Chapter 6.6), impossible when require("./" + name) might load anything; (3) static tooling generally — precise refactors, dead-code detection, cycle linting.

CommonJSES Modules
Syntaxrequire() / module.exportsimport / export
Loadingsynchronous, at call timeasynchronous, statically resolved
Placementanywhere (function call)top level, literal paths
Analyzable ahead of timenoyes → tree shaking
Exported valuesa copied value (the exports object)live bindings (section 4)
Top-level thismodule.exportsundefined (always strict)
Home groundlegacy Node defaultbrowsers + modern Node — the standard

ESM also kept a controlled escape hatch for genuinely dynamic cases: the dynamic import expression import("./chart.js"), callable anywhere, returning a Promise of the module — the mechanism behind route-level code splitting ("load the admin bundle only when an admin logs in", Chapter 6.6). Static by default, dynamic on request — both worlds, each in its place.

The migration between the two systems has been painfully long because Node's whole ecosystem was built on CJS — hence "type": "module" in package.json, the .mjs/.cjs extensions, and the interop rules (ESM can import CJS; CJS cannot require ESM synchronously). Chapter 3.8 and 3.10 cover the Node-side specifics.

4. Single-pass loading, live bindings, and the undefined import

Now the everyday mystery. Why is an imported value sometimes undefined even though the exporting file plainly assigns it?

The first ingredient: a module executes once, top to bottom, the first time anything imports it; the result is cached, and every later import receives the cached module without re-running it — single-pass loading. This is a feature: it makes modules efficient and gives them singleton semantics (every importer shares the same instance — which is why a module that creates a DB connection pool or config object works as a natural shared resource, and why module-level mutable state is effectively global state).

The second ingredient: a circular dependency — A imports B while B imports A. Something must run first:

A.jsstarts running…exports NOT yet assignedB.jsruns to completionreads A.thing → undefined① A imports B② B imports A — mid-execution!The loader returns the incomplete A to avoid infinite recursion → B sees undefined
Figure 1 — A circular import. A begins executing and pulls in B; B in turn asks for A, but A hasn't reached its export assignments — so the loader hands B the partially-initialised A, and the read yields undefined.

Trace it: A starts executing and imports B. B starts and imports A — but A is already mid-execution, so the loader (refusing infinite recursion) hands B the partially-initialised A. B reads an export that hasn't been assigned yet: undefined. The maddening inconsistency now explains itself: if B uses the value at top level, it crashes; if B only touches it later, inside a function — by which time A has finished — everything works. Same cycle, different timing. JavaScript module loading — single-pass loading, and why undefined occurs during import. [EQ-196]

ESM softens (without solving) this via live bindings: an imported name is not a copied value but a live view of the exporter's variable — hoisted declarations plus late assignments become visible through it:

javascript
// counter.js
export let count = 0;
export function increment() { count++; }   // reassigns the exporter's variable

// app.js
import { count, increment } from "./counter.js";
increment();
console.log(count);   // → 1  ✅ ESM: live view of the variable
// CJS equivalent would log 0 — require() copied the VALUE at import time

Live bindings mean function declarations survive cycles better (they hoist — the binding is populated before execution) and late assignments propagate. But an undefined read before assignment is still undefined at that moment — the ordering problem is fundamental. Fixes, in order: (1) break the cycle — it almost always signals a boundary drawn wrongly; extract the shared piece into a third module both import; (2) defer the access into a function body so it runs after loading completes; (3) lazy import() at the point of use, as a last resort. And lint it: import/no-cycle turns mystery startup crashes into CI failures.

5. The expert lens

Static structure is what makes tooling possible — that's why ESM won. Top-level-only, literal-path imports look like a bureaucratic restriction until you see the payoff: a statically knowable graph enables tree shaking, bundling, code splitting, and async loading. You've now met this trade three times — static types enable refactoring tools (3.3), ASTs enable correct renames (3.1), static imports enable dead-code elimination. Same principle each time: constraining what a program may express makes it analysable, and analysability is where tooling leverage comes from. When a restriction feels arbitrary, ask what analysis it buys.

Modules are the privacy story's final chapter. Namespace object → IIFE module pattern → ESM is one continuous arc: each step moves privacy from convention to mechanism. An ES module's top-level variables are genuinely private by default (module scope, not global), exports are the explicit public surface, and the singleton cache gives shared state a disciplined home. The old patterns still matter twice over: you'll read them in every legacy codebase, and they're the proof that closures can build language features — worth having seen once, properly.

Circular dependencies are a design signal, not just a bug. The undefined import is the symptom; the disease is two modules mutually dependent — usually meaning the boundary is misdrawn or a third concept is unnamed. Extracting it fixes the bug and the design. This scales directly: circular dependencies between services (Part 10) cause startup deadlocks and coupled deploys, with the same remedy. Treat every cycle as feedback about your decomposition.

Next: 3.6.6 — the iteration protocols, and the language's most under-taught power feature: generator functions.

Recall

  • No modules → one shared global scope → collisions. Workarounds, in order: namespace objects (one global, dotted hierarchy, no privacy) → IIFE module pattern/revealing module pattern (closure privacy + explicit public interface).
  • CommonJS: require/module.exports, synchronous + dynamic, exports are copied values. ES Modules: import/export, static (top-level, literal paths) → async loading, tree shaking, tooling; dynamic import() is the promise-returning escape hatch powering code splitting.
  • Single-pass loading: a module runs once, is cached, and every importer shares it — singleton semantics (shared pools/config; also: module state = global state).
  • Circular dependency + single-pass ⇒ the second module sees a partially-initialised first module ⇒ undefined imports that only crash when read at top level. ESM's live bindings (imports are views of the exporter's variable, not copies) soften but don't solve it.
  • Fixes: break the cycle via a third shared module (best — the cycle is a design smell), defer access into functions, lazy import(); lint with import/no-cycle.

Self-test: What privacy does a namespace object lack and how does the IIFE pattern add it? Name the defining CJS/ESM difference and two capabilities it enables. Why exactly can an import be undefined, and why does moving the use into a function "fix" it? What is a live binding, and what would the CJS version of the counter example log?

Quiz Bank

FoundationalWhat is the module pattern, and what problem did it solve?

Early JavaScript had no modules — every script shared one global scope, so libraries collided and nothing was private. The module pattern solved it with closures (3.6.2): wrap code in an immediately-invoked function (IIFE) so variables become function-scoped and unreachable, and return an object exposing only the chosen public interface (the revealing module pattern variant returns named internal functions, making the public surface read like a table of contents). This achieved real encapsulation with zero language support. Its sibling workaround, the namespace object (MyApp.utils.x), reduced global pollution to one name but provided no privacy. The arc namespace → IIFE → ESM is privacy moving from convention to mechanism.

FoundationalWhat is the key difference between CommonJS require and ES Module import?

CommonJS require() is synchronous and dynamic: it loads and executes the file at the moment the call runs, anywhere in code, with computable paths — fine for a server reading local disk, impossible over a network. ES Modules import is static: top-level only, literal paths, so the entire dependency graph is known before execution. That enables async parallel fetching in browsers, tree shaking (prove-and-delete unused exports — impossible with dynamic require), and precise tooling. Two more differences that bite: CJS exports are copied values while ESM exports are live bindings (views of the exporter's variables); and ESM modules are always strict with undefined top-level this. Dynamic needs in ESM use the import() expression — asynchronous, promise-returning, the basis of code splitting.

AppliedWhat does 'modules are singletons' mean, and why does it matter?

Single-pass loading: a module's body runs once, on first import; its exports are cached; every subsequent importer receives the same instance. That's singleton semantics for free — the idiomatic home for shared resources: one DB pool, one config object, one logger, shared by all importers. The hazard is the same fact inverted: module-level mutable state is global state — mutations are visible program-wide and live for the process lifetime, causing cross-test pollution (tests importing the module share its state; hence test runners' module-registry resets) and unbounded module-level caches turning into logical leaks (3.4). Discipline: export factories or explicit state containers when isolation matters; keep module scope for genuinely shared, ideally immutable, things.

InterviewWhy is an imported value sometimes undefined?

Single-pass loading meeting a circular dependency. A module executes once and is cached; if A imports B and B imports A, then when A (mid-execution) triggers B, B's request for A cannot re-run A — the loader hands B the partially-initialised A whose exports aren't assigned yet, so B reads undefined. The bug feels inconsistent because timing decides: a top-level read crashes; a read inside a function called later (after A finished) works. ESM's live bindings and hoisting soften it — function declarations are populated early, and late assignments become visible through the binding — but a read before assignment is still undefined. Fixes: break the cycle by extracting the shared concept into a third module (the real fix — the cycle is a design smell), defer access into functions, or lazy import(); enforce with import/no-cycle lint.

InterviewWhat are ESM live bindings, and how do they differ from CJS exports?

An ESM import is not a copy — it's a live, read-only view of the exporter's variable. If counter.js does export let count = 0 and later count++ (inside an exported increment), every importer reading count sees the updated value; the binding tracks the variable, echoing the capture-by-slot rule of closures (3.6.2). CJS instead assigns a value onto module.exports; const { count } = require(…) destructures a snapshot at import time — later internal reassignment of the exporter's local variable is invisible to importers (unless the export is a getter or the module mutates a shared object). Consequences: ESM handles cycles more gracefully (hoisted function bindings exist before execution; late assignments propagate), enables accurate tree-shaking semantics, and forbids importers from assigning to imports (compile-time error) — the one-way data flow that keeps the graph analysable.

StaffWhy do static import restrictions (top-level, literal paths) exist, and what general principle do they illustrate?

They make the dependency graph statically analysable — knowable without executing the program. Because import cannot be conditional or computed, tools can determine the complete graph from source alone, enabling: parallel async fetching in browsers (impossible with synchronous require), tree shaking (prove an export unused anywhere → delete it, Chapter 6.6), reliable bundling/code-splitting boundaries, cycle detection in CI, and safe automated refactors. The escape hatch for legitimately dynamic loading was deliberately designed as an expression returning a promiseimport() — so even dynamism is explicit and awaitable rather than ambient. The recurring principle: constraining expressiveness buys analysability, and analysability is where tooling leverage lives — the same trade as static types (3.3) and AST-based tooling (3.1). Senior reflex: when a platform restriction feels arbitrary, identify the analysis it enables before judging it.

Flashcards

FlashNamespace vs module pattern

Namespace: one global object, dotted hierarchy, zero privacy. Module pattern: IIFE + closure privacy, returns explicit public interface.

FlashCJS vs ESM

CJS: require/module.exports, sync, dynamic, value copies. ESM: import/export, static top-level literal paths → async + tree shaking; live bindings; always strict.

FlashSingle-pass loading

Module body runs once, result cached, all importers share it — singleton semantics; module state = global state.

FlashUndefined import, in one line

Circular dependency: the second module receives a partially-initialised first module whose exports aren't assigned yet.

FlashLive binding

ESM import = live read-only view of the exporter's variable (not a copy) — late assignments visible; importers can't assign to it.

FlashDynamic import()

Expression, works anywhere, returns a Promise of the module — the ESM escape hatch behind route-level code splitting.

Scenario Drill

DrillA Node service crashes at startup with TypeError: X is not a function — but only when a particular route module is loaded first. Diagnose and fix using this page.

Order-dependence is the decisive clue: the same code works or fails depending on which module the loader reaches first — squarely a circular dependency interacting with single-pass loading. Mechanism: module A begins executing and imports B; B imports A; A is mid-execution, so B receives the partially-initialised A whose export X is still undefined — calling it yields exactly "X is not a function." Whether it crashes depends on whether the call happens at module top level (immediately, while A is incomplete) or inside a handler invoked later (after A finished) — which is why load order flips the outcome.

Diagnose: map the graph — read the imports along the failing path, or run a cycle detector (madge --circular, ESLint import/no-cycle, bundler warnings) — and confirm the loop (often A→B→C→A, and often created accidentally by a barrel index.js that re-exports everything, making innocent imports pull the whole ring).

Fix, in order: (1) break the cycle — the two modules share an unnamed concept (a type, constant, or service); extract it into a third module both import, making the graph acyclic; this is the real fix because the cycle signals a misdrawn boundary; (2) defer the access into the function that needs it, so resolution happens post-load; (3) replace broad barrel imports with direct file imports to stop importing the ring; (4) last resort, lazy import() at call site.

Prevent recurrence: import/no-cycle in CI, and a convention that barrels only re-export leaf modules. Close with the scaling note: the same pattern between services produces startup deadlocks and coupled deploys (Part 10) — same remedy, extract the shared dependency.