โ† All Chapters

Chapter 2

The Module System

Pages 17-62nodejsmodulescommonjsesmrequire
Ch 1Ch 2Ch 3Ch 4Ch 5Ch 6Ch 7Ch 8Ch 9Ch 10Ch 11Ch 12Ch 13

Chapter 2: The Module System

Summary

Modules are the fundamental mechanism for structuring Node.js applications. This chapter traces the evolution from the revealing module pattern (IIFE-based encapsulation) through CommonJS (Node's original require/module.exports system) to ESM (the official JavaScript standard with import/export).

The chapter builds a homemade module loader to show exactly how require() works internally: wrapping source code in a function, creating a module object, caching it before execution (critical for circular dependencies), then evaluating the code. Understanding this mechanism demystifies module.exports vs exports, the singleton cache, and circular dependency behavior.

The central lessons are: CommonJS loads synchronously and returns a cached object (snapshot semantics on primitives), while ESM uses live bindings and loads in three phases (parsing, instantiation, evaluation). Both systems handle circular dependencies, but through fundamentally different mechanisms -- CommonJS returns partially loaded exports, while ESM leverages live bindings for a cleaner resolution.

Key Concepts

The Need for Modules

โ„น๏ธInfo

Why Modules Matter

  1. Code splitting -- break large programs into manageable files
  2. Reusability -- use the same code across projects
  3. Encapsulation -- hide internal implementation, expose only the API
  4. Dependency management -- explicitly declare what a module needs

Without a module system, everything lives in the global scope -- name collisions, implicit dependencies, and no encapsulation. Modules solve all of these.

The Revealing Module Pattern

const myModule = (() => {
  // Private scope -- not accessible outside
  const privateFoo = 'secret';
  const privateBar = () => privateFoo;

  // Public API -- only these are exposed
  return {
    getSecret: privateBar
  };
})();

myModule.getSecret();   // 'secret'
myModule.privateFoo;    // undefined -- truly private
๐Ÿ’กTip

Conceptual Foundation The IIFE creates a closure that acts as a private scope. Only properties returned in the object are public. This is exactly what Node.js does under the hood -- it wraps each file in a function to create module scope. The revealing module pattern is the conceptual ancestor of CommonJS.

CommonJS Modules

Node.js wraps every module file in a function:

(function(exports, require, module, __filename, __dirname) {
  // Your module code lives here
});

This gives each module its own scope with access to exports, require, module, __filename, and __dirname -- injected as function parameters, not globals.

The Homemade Module Loader

The book builds a simplified require() to show how it works. The key insight:

function loadModule(filename, module, require) {
  const wrappedSrc = `(function(module, exports, require, __filename, __dirname) {
    ${fs.readFileSync(filename, 'utf8')}
  })(module, module.exports, require, filename, dirname)`;
  eval(wrappedSrc);
}

The source code is wrapped in a function that receives module/exports/require as parameters. This is why top-level variables in a module don't leak globally -- they are local to the wrapper function.

How require() Works -- The 6 Steps

require('myModule')
     |
     v
+-------------+     +--------------+
| 1. Resolve  |---->| Full path    |
|    path     |     | of the file  |
+-------------+     +--------------+
     |
     v
+-------------+     +------------------+
| 2. Check    |-YES>| Return cached    |
|    cache    |     | module.exports   |
+-------------+     +------------------+
     | NO
     v
+-------------+
| 3. Create   |  new Module({ id, exports: {}, loaded: false })
|    module   |
+-------------+
     |
     v
+-------------+
| 4. Cache it |  require.cache[resolvedPath] = module
|  (BEFORE    |  <-- Critical for circular deps!
|   loading!) |
+-------------+
     |
     v
+-------------+
| 5. Load &   |  Read file, wrap in function, execute
|    evaluate |
+-------------+
     |
     v
+-------------+
| 6. Return   |  return module.exports
|    exports  |
+-------------+
โš ๏ธWarning

Step 4 is Critical The module is cached before its code runs. This is how Node.js handles circular dependencies -- when module B requires module A (which is still loading), it gets A's partially-loaded exports from the cache. Without pre-caching, circular dependencies would cause infinite recursion.

module.exports vs exports

// exports is just a shorthand reference
exports === module.exports  // true (initially)

// Adding properties works fine
exports.hello = 'world';         // modifies module.exports
module.exports.hello = 'world';  // same thing

// Reassigning exports BREAKS the reference
exports = { hello: 'world' };    // exports now points to NEW object
                                 // module.exports is still {}

// Reassigning module.exports WORKS
module.exports = { hello: 'world' }; // this is what require() returns
๐Ÿ”ดDanger

Common Gotcha require() always returns module.exports, never exports. If you reassign exports = { ... }, you're only changing a local variable. module.exports still points to the original object. The link is broken. Always use module.exports when replacing the entire export.

require() is Synchronous

require() blocks the event loop while it reads and executes the module file. This is why:

  • You can use const fs = require('fs') and immediately call fs.readFile()
  • Module loading is acceptable at startup but expensive in hot paths
  • ESM was designed to be asynchronous (supporting browser HTTP fetching)

The Resolving Algorithm

require('X') from module at path Y:

1. If X is a core module (fs, path, http...)
   -> return core module

2. If X starts with './' or '/' or '../'
   -> resolve as file: X, X.js, X.json, X.node
   -> resolve as directory: X/index.js, X/index.json, X/index.node
   -> or check X/package.json -> "main" field

3. Otherwise (bare package name like 'lodash')
   -> look in ./node_modules/X
   -> look in ../node_modules/X
   -> look in ../../node_modules/X
   -> ... walk up to filesystem root

This "walk up" algorithm is how Node.js solves dependency hell -- each package can have its own node_modules/ with its own version of a dependency. Package A can use lodash@3 while package B uses lodash@4, with no conflicts.

Module Cache

// counter.js
let count = 0;
module.exports = { increment: () => ++count, getCount: () => count };

// a.js
const counter = require('./counter');
counter.increment();
counter.increment();

// b.js
const counter = require('./counter');
console.log(counter.getCount()); // 2 -- same instance as a.js!
โ„น๏ธInfo

Singleton-Like Behavior The cache means every require('./counter') returns the same object. State is shared across all consumers. The cache is stored in require.cache keyed by the resolved absolute filename.

Circular Dependencies

// a.js
exports.loaded = false;
const b = require('./b');    // B starts loading here
exports.loaded = true;       // This runs AFTER b.js finishes
console.log('b.loaded:', b.loaded); // true

// b.js
const a = require('./a');    // Gets a's PARTIAL exports: { loaded: false }
exports.loaded = true;
console.log('a.loaded:', a.loaded); // false (!)
โš ๏ธWarning

Partial Exports When b.js requires a.js, it gets whatever a.js has exported SO FAR. Since a.js hasn't finished executing (it's waiting for b.js), a.loaded is still false. Circular deps work but can produce surprising results. This works only because step 4 caches the module before step 5 executes it.

Module Definition Patterns

PatternSyntaxUse Case
Named exportsexports.foo = ...Multiple related utilities
Export a functionmodule.exports = function() {}Single-purpose module (substack pattern)
Export a classmodule.exports = class {}Stateful objects
Export an instancemodule.exports = new Foo()Singleton / shared state
Monkey patchingModify other modules/globalsPlugins, polyfills (use sparingly)

The substack pattern (exporting a single function) is considered the most focused: module.exports = function myFunction() { ... }. Named after the prolific npm contributor who advocated small, single-purpose modules.

ESM (ECMAScript Modules)

Named and Default Exports

// Named exports
export const PI = 3.14159;
export function circleArea(r) { return PI * r ** 2; }

// Default export
export default class Logger { /* ... */ }

// Importing
import Logger, { PI, circleArea } from './math.js';
import { PI as pi } from './math.js';           // Rename
import * as math from './math.js';               // Namespace

Dynamic import()

// Conditional loading
if (needsFeature) {
  const { feature } = await import('./feature.js');
}

// Computed specifier
const lang = getUserLang();
const strings = await import(`./i18n/${lang}.js`);
๐Ÿ’กTip

import() Works Everywhere import() returns a Promise and can be used in both ESM and CommonJS. It's the ONLY way to load ESM from CommonJS code.

ESM Loading Phases

Phase 1: PARSING
  Parse all source files, build dependency graph
  (static analysis -- no code runs)
       |
       v
Phase 2: INSTANTIATION
  Create live bindings for all exports/imports
  (memory allocated, connections made, no values yet)
       |
       v
Phase 3: EVALUATION
  Execute code in dependency order (deepest dependency first)
  (values flow through the live bindings)

These three phases are possible because import/export are static syntax -- they must appear at the top level and cannot be conditional. The engine knows the full dependency graph before any code runs, enabling tree-shaking and static analysis.

Live Bindings

// counter.mjs
export let count = 0;
export function increment() { count++; }

// main.mjs
import { count, increment } from './counter.mjs';
console.log(count);    // 0
increment();
console.log(count);    // 1 -- LIVE! reflects the change

count = 5;             // TypeError: read-only binding
โ„น๏ธInfo

CJS Snapshot vs ESM Live Bindings CommonJS: require() returns an object. Primitive values on it are snapshots. ESM: import creates a live, read-only reference. Changes in the exporter ARE reflected in the importer. This also enables cleaner circular dependency resolution -- a circular import gets a binding that will be filled in when the exporting module evaluates.

ESM vs CommonJS

FeatureCommonJSESM
Syntaxrequire() / module.exportsimport / export
LoadingSynchronousAsynchronous (3 phases)
BindingsObject properties (snapshot for primitives)Live, read-only
Strict modeOptionalAlways on
__filename, __dirnameAvailableNot available (use import.meta.url)
require functionAvailableNot available (use import or import())
this at top levelmodule.exportsundefined
Conditional importif (x) require(y)if (x) await import(y)
File extensions.js, .cjs.mjs or .js with "type": "module"
JSON importrequire('./data.json')Experimental / import assertion
Circular depsPartial exports (snapshot)Live bindings (cleaner)
Tree-shakingNot possible (dynamic)Possible (static analysis)

Interoperability

// ESM importing CommonJS -- works
import cjsModule from './legacy.cjs';       // default = module.exports
import { named } from './legacy.cjs';       // may work (static analysis)

// CommonJS importing ESM -- must use import()
const esmModule = await import('./modern.mjs');
โš ๏ธWarning

The Asymmetry ESM can import CJS freely. CJS cannot require() ESM -- must use async import(). This asymmetry is the biggest source of friction when migrating codebases or mixing module types.

Mind Map

Connections

  • Previous: Chapter 1 -- The Node.js Platform
  • Next: Chapter 3 -- Callbacks and Events
  • Module patterns (named exports, export function) are used throughout the Streams and Behavioral Design Patterns chapters
  • The singleton pattern via module cache connects to Creational Design Patterns
  • The wrapper function concept ties back to the IIFE / revealing module pattern

20 quiz ยท 25 cards ยท 2 exercises ยท Ch 2 of 13