Skip to content
academia.sh

Lesson 03 / 14

CommonJS

The call-based loading model, the module wrapper and cache, the difference between a value copy and a live binding, the two systems' behavior on circular dependencies, and interoperability.

Contents

The previous lesson established a model where import is a statement and binding completes before evaluation. The language has a second module system that became widespread earlier, and it still carries a large share of the code that runs.

The two existing side by side is not a transitional accident, it is a permanent state: in a single project, files from both systems sit next to each other and call one another. This lesson’s question is what the second system does differently, and where the boundary between the two models sits.

Loading Is a Function Call

In this system, obtaining a module means calling a function named require; exporting means writing to an object named module.exports. Neither is syntax — both are ordinary runtime operations.

This means the source text is never run directly. The runtime wraps the file’s content in a function body and calls that function with five arguments:

// file: wrapper.cjs
console.log(typeof require, typeof module, typeof exports);
console.log(__filename.endsWith('wrapper.cjs'), typeof __dirname);
console.log(module.exports === exports);
$ node wrapper.cjs
function object object
true string
true

These five names are not part of the language; they are supplied by the module wrapper. Module scope here is the same isolation obtained at the parsing level in the previous lesson, obtained instead through an ordinary function scope. The result is identical: top-level declarations do not leak into the global scope.

The last line also shows a trap: exports starts out as the same object as module.exports. Writing exports.name = ... affects both, but writing exports = ... changes only the local name and does not touch the export surface. To replace the surface entirely, module.exports has to be assigned.

A direct consequence of being call-based is that loading can be conditional and computed:

// file: format-plain.cjs
module.exports = (measurement) => `word: ${measurement.wordCount}`;
// file: choice.cjs — format-plain.cjs is the file above
const mode = process.argv[2] ?? 'plain';
const format = require(`./format-${mode}.cjs`);

console.log(format({ wordCount: 13, sentenceCount: 2 }));
$ node choice.cjs
word: 13

Since the specifier is computed at runtime, which file gets loaded cannot be known by reading the source. This is the price of that flexibility: tools that do static analysis cannot see this dependency, and tree shaking cannot be applied.

The Cache and Single Evaluation

When the same file is requested more than once, its body runs exactly once. The runtime keeps a cache keyed by the resolved file path:

// file: counter.cjs
console.log('counter.cjs body ran');
let count = 0;
module.exports = {
  increment() { count += 1; return count; },
  get value() { return count; },
};
// file: cache.cjs — counter.cjs is the file above
const a = require('./counter.cjs');
const b = require('./counter.cjs');

console.log('same object:', a === b);
a.increment();
console.log('read through b:', b.value);
console.log('registered in cache:', require.resolve('./counter.cjs') in require.cache);
$ node cache.cjs
counter.cjs body ran
same object: true
read through b: 1
registered in cache: true

The body ran once, and the two calls gave the same object. The fourth of the four guarantees listed in the previous lesson — single evaluation — is realized here through an observable table. The cache’s key is the resolved absolute path; if the same file is reached through different paths, the entry differs too.

Copy and Live Binding

module.exports is an ordinary object; importing is an ordinary read. This means the previous lesson’s live-binding behavior does not exist here:

// file: record.cjs
let callCount = 0;
function count() { callCount += 1; }

module.exports = { callCount, count };
// file: copy.cjs — record.cjs is the file above
const { callCount, count } = require('./record.cjs');
const whole = require('./record.cjs');

count();
count();
console.log('taken by destructuring:', callCount);
console.log('through the object:', whole.callCount);
$ node copy.cjs
taken by destructuring: 0
through the object: 0

Both reads gave zero. When the module.exports object was built, callCount’s value at that moment was copied into the object; the increments that followed changed the local variable but not the object. The same scenario gave 2 in standard module syntax.

This difference is a source of silent bugs in code moved between the two systems. If a changing counter or flag is being exported, the call-based system needs a getter computed at read time — this is exactly what the get value definition in the counter.cjs example was for.

Circular Dependency

When two modules import each other, the graph contains a cycle. No system can resolve this cycle; what they can do is break it at a specific point. Where they break it differs between the two systems.

In the call-based system, require finds a partially filled export object in the cache and returns it:

// file: a.cjs
console.log('a: body started');
exports.name = 'A';
const b = require('./b.cjs');
console.log('a: b.name =', b.name, '| b.define =', typeof b.define);
exports.define = function () { return 'A description'; };
console.log('a: body finished');
// file: b.cjs
console.log('b: body started');
const a = require('./a.cjs');
console.log('b: a.name =', a.name, '| a.define =', typeof a.define);
exports.name = 'B';
exports.define = function () { return 'B description'; };
console.log('b: body finished');
$ node a.cjs
a: body started
b: body started
b: a.name = A | a.define = undefined
b: body finished
a: b.name = B | b.define = function
a: body finished
(node:4526) Warning: Accessing non-existent property 'define' of module exports inside circular dependency
(Use `node --trace-warnings ...` to show where the warning was created)

b.cjs saw a.cjs in a half-run state: name was defined, define was not. The program did not crash; it gave a missing value. The process number in the warning line changes on every run.

In standard module syntax, since binding completes before evaluation, the names already exist; the problem is that a name’s value has not been assigned yet. Function declarations are ready before the body runs, so they can be used without trouble:

// file: a.mjs
import { bName, bDefine } from './b.mjs';

console.log('a: body started');
export const aName = 'A';
export function aDefine() { return 'A description'; }
console.log('a: b.bName =', bName, '| bDefine() =', bDefine());
// file: b.mjs
import { aName, aDefine } from './a.mjs';

console.log('b: body started');
console.log('b: aDefine() =', aDefine());
export const bName = 'B';
export function bDefine() { return 'B description'; }
$ node a.mjs
b: body started
b: aDefine() = A description
a: body started
a: b.bName = B | bDefine() = B description

b.mjs was able to call a function from a.mjs, even though a.mjs’s body had not run yet. In the same mechanism, an early access to a value defined with const does not give a silent undefined, it throws:

// file: a2.mjs
import { bName } from './b2.mjs';

export const aName = 'A';
console.log('a2: bName =', bName);
// file: b2.mjs
import { aName } from './a2.mjs';

console.log('b2: aName =', aName);
export const bName = 'B';
$ node a2.mjs 2>&1 | grep '^ReferenceError'
ReferenceError: Cannot access 'aName' before initialization

The distinction can be summed up: one lets missing data pass silently, the other stops the access. In both, the real fix is the same — a circular dependency is a design smell, and it is resolved by extracting the shared part into a third module.

Interoperability

When the two systems coexist in the same project, which file is read under which rule is decided by a marker: the .mjs extension forces the standard module, .cjs forces the call-based module; for files with a .js extension, the type field in the nearest manifest is consulted.

Access from the standard module to the call-based module is direct:

// file: legacy-format.cjs
function format(number) { return number.toFixed(2); }

module.exports = { format, version: '1.0.0' };
// file: consumer.mjs — legacy-format.cjs is the file above
import legacy from './legacy-format.cjs';
import { format } from './legacy-format.cjs';
import { createRequire } from 'node:module';

const require = createRequire(import.meta.url);
const manual = require('./legacy-format.cjs');

console.log('default export:', legacy);
console.log('named import:', format(3.14159));
console.log('with createRequire:', manual === legacy);
$ node consumer.mjs
default export: { format: [Function: format], version: '1.0.0' }
named import: 3.14
with createRequire: true

The module.exports object arrives as the default export. Whether named import works depends on an inference: the runtime scans the source and guesses which names got assigned. If the assignments happen inside a loop or with computed keys, this guess fails, and only the default export can be used.

The reverse direction is more restricted. require is a synchronous call: it has to return its value immediately. A standard module graph, though, can contain top-level await, meaning its evaluation can be asynchronous. A graph like that cannot be loaded with a synchronous call:

// file: pending.mjs
const delay = await new Promise((settle) => setTimeout(() => settle('ready'), 10));
export const status = delay;
// file: pending-require.cjs — pending.mjs is the file above
const m = require('./pending.mjs');
console.log(m.status);
$ node pending-require.cjs 2>&1 | grep '^Error'
Error [ERR_REQUIRE_ASYNC_MODULE]: require() cannot be used on an ESM graph with top-level await. Use import() instead. To see where the top-level await comes from, use --experimental-print-required-tla.

This boundary is a technical necessity, not a shortcoming: a synchronous call cannot wait for work whose completion spreads out over time. The solution the error message points to is the next lesson’s subject.

Summary

  • In the call-based system, importing is a function call, exporting is writing to an object; the specifier can be computed at runtime, which is why static analysis cannot be done.
  • The module wrapper provides scope isolation through an ordinary function scope, and supplies the names require, module, exports from outside.
  • Loaded modules are cached by resolved path; the body runs once, and every call gets the same object.
  • Exported primitive values are copies; a getter has to be defined for live-binding behavior.
  • On a circular dependency, the call-based system gives a partially filled object; the standard system stops early access with an error.
  • The extension and the type field decide which rule applies; a synchronous call cannot load a graph that contains top-level await.

Next Step

There is a form that does what a synchronous call cannot: an import that returns a promise, can be called at runtime, and can have a computed specifier. The next lesson takes up that form, the code-splitting and lazy-loading patterns, and the module registry’s role in those patterns.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close