Modules

Split a program across files with both module systems, and know which one you are in.

02of 18
≈ 11Min read
8Parts

Two systems, both alive

ESM is the standard and the default for anything new. CommonJS is everywhere in existing code and in older packages, so you will read both for years yet.

CommonJSES Modules
Importconst x = require("./x.js")import x from "./x.js"
Exportmodule.exports = …export / export default
File extension.js (default) or .cjs.mjs, or .js with "type": "module"
LoadingSynchronous, at the callParsed first, then executed
__dirnameyesno — import.meta.dirname (20.11+)
Top-level awaitnoyes

CommonJS

math.js, then app.js
// math.js — one object, however you like to build it
function add(a, b) { return a + b; }
function mul(a, b) { return a * b; }

module.exports = { add, mul };
// or: exports.add = add;  exports.mul = mul;

// app.js
const { add } = require("./math.js");
const math = require("./math.js");   // the SAME object — modules are cached
exports = … does nothing

exports is just a variable pointing at module.exports. Reassigning it points the local variable somewhere else and exports nothing. Assign to module.exports, or add properties to exports.

ES Modules

  • The file extension is required in Node, unlike in bundlers: "./math.js", not "./math".
  • Imports are hoisted and resolved before any code runs, which is what makes static analysis and tree shaking possible.
  • Named exports beat a default export: they can be autocompleted, renamed and searched for.
the shapes you will write
// math.js
export function add(a, b) { return a + b; }
export const PI = 3.14159;
export default class Calculator {}

// app.js
import Calculator, { add, PI } from "./math.js";
import * as math from "./math.js";
import { add as plus } from "./math.js";

// loaded only when the line runs, and returns a promise
const heavy = await import("./heavy.js");

Which one am I in?

Without "type": "module", every .js file is CommonJS. With it, require is not defined and import is. You can always be explicit with .mjs and .cjs.

package.json decides for .js files
{
  "name": "doors",
  "type": "module",        // .js files are ESM;  .cjs is still CommonJS
  "main": "./src/app.js"
}

Resolution: where a name points

A bare name is searched for in node_modules in the current folder, then its parent, then its parent, up to the root. That walk is why a dependency installed three levels up still resolves — and why two versions of the same package can coexist.

three kinds of specifier
require("./utils.js")     // relative — a file next to this one
require("/etc/config.js") // absolute
require("express")        // a package: node_modules, walking up the folders
require("node:fs")        // a built-in — the node: prefix is explicit and faster to read

Modules are cached, and run once

This makes a module a convenient singleton — a database pool, a logger, a config object. It also means any code at the top level of a module is a side effect that fires the first time anyone imports it.

the side effect happens exactly once
// counter.js
let n = 0;
module.exports = { next: () => ++n };

// two files requiring it share the same n
const a = require("./counter.js");
const b = require("./counter.js");
a.next(); b.next();   // 2

Circular imports

Neither module system makes this pleasant. The fix is structural: move the shared piece into a third module both can depend on.

the classic half-loaded object
// a.js
const b = require("./b.js");   // b.js runs now…
module.exports = { name: "a" };

// b.js
const a = require("./a.js");   // …and gets {} — a.js has not finished
console.log(a.name);            // undefined

Interop, briefly

one direction is easy, the other is not
// ESM can import CommonJS — the exports arrive as the default
import pkg from "commonjs-package";
const { thing } = pkg;

// CommonJS cannot require ESM synchronously (before Node 22)
const mod = await import("./esm-module.js");

Practice