9.3 — Modules in Node: CJS vs ESM
Twice now a note has said “Node has TWO module systems — 9.3 untangles them” (8.1), and 8.6 showed you the mysterious "type": "module" switch. Time to pay the debt: Node speaks both CommonJS (its own invention, 2009) and ES Modules (8.1’s standard, 2015) — and one package.json line decides which your files speak.
After today you can read any Node file cold — old tutorial or new config — without blinking at require.
// ---- CommonJS (Node’s own, 2009) ----
// math.cjs
const TAX = 0.25;
function withTax(p) { return p * (1 + TAX); }
module.exports = { withTax };
// cart.cjs
const { withTax } = require("./math.cjs");
console.log(withTax(100));
// ---- ES Modules (the 8.1 standard) ----
// needs "type": "module" in package.json
import { withTax } from "./math.js";The timeline explains everything. Node shipped in 2009; the language didn’t get import/export until 2015. Six years of real projects needed SOME way to split files — so Node invented its own system: CommonJS. Not a rival standard by arrogance; a gap-filler by necessity.
The deeper story, with the real names for things — this part is what turns “I saw it” into “I can explain it.”
Under CommonJS, Node literally wraps every file in a hidden function — function (exports, require, module, __filename, __dirname) { … } — and calls it. That’s why require and module “just exist” in CJS files: they’re parameters (3.1!), not globals. ESM files don’t get this wrapper, which is also why __dirname vanishes there (9.5 shows the replacement).
The deeper difference from 8.1 still applies: ESM imports are live bindings resolved before execution; CJS hands you a snapshot object whenever the require call happens to run. Most days this changes nothing — it matters exactly when you hit circular imports, and then it matters a lot.
ESM in Node also unlocks top-level await (6.6’s await outside any function) — CJS files can’t do that, another reason new projects flip the switch.
Job note: TypeScript adds a twist you’ll meet in Phase 11 — your playwright.config.ts is written with import/export, and the compiler outputs whichever system the project’s settings ask for. You write the standard; tooling handles the costume.
⌨️ build require yourself
CommonJS is refreshingly unmagical: exports is a plain object, require is a plain function. Prove it by building the whole mechanism in the sandbox.
requirements:
- Model a module: create
moduleBoxas an object whoseexportsproperty starts as an empty object. - Model the module’s sealed file scope with a bare
{ }block (3.5’s bubble!). Inside it:TAXset to0.25,withTaxas a const arrow function returningprice * (1 + TAX), and finally assignmoduleBox.exportsan object holdingwithTax. - Outside the block, write
requireFrom(box): it simply returns the box’sexports. DestructurewithTaxout of arequireFrom(moduleBox)call (4.11) — the name is free out here, because the block kept the module’s own copy private. - Print
withTax(100), then printtypeof requireFrom(moduleBox)— proof that what require hands back is just an object.
when you press RUN, the console must show exactly:
✏️ Quick check 1
A file’s top line is: const { test } = require("@playwright/test"). Which module system — CommonJS or ESM?
✏️ Quick check 2
A default Node project (no "type" field in package.json) — which system do its .js files speak?
✏️ Quick check 3
Is require a keyword like import, or a normal function? Type keyword or function.
🗣️ Now teach it back
Explain to a friend why Node has two module systems, how to recognize each in one glance, and what "type": "module" in package.json actually does.
Write it as if your friend is sitting next to you. Saved to your journal — future-you will use these notes to teach others.