Skip to main content
CodeOath
← All posts

Node.js95 min total · 14 parts

Node.js Fundamentals: The Runtime, the Event Loop, and Building Real APIs

Part 4 of 14 · ~5 min

Modules & npm

billhook starts life as a plain CommonJS script, because that's what every other Node service at Northbound already is, and picking a fight about module systems on day one isn't the hill Ines wants to die on.

CommonJS

// verify.js
function isValidSignature(rawBody, signature, secret) { /* ... */ }
module.exports = { isValidSignature };

// server.js
const { isValidSignature } = require("./verify");

require() is synchronous — it reads, compiles, and runs the target file before returning control to whoever called it — and Node caches the result by resolved file path, so require("./verify") a second time anywhere in the process returns the exact same exported object rather than re-executing the file.

ES Modules, and the import that didn't work

Three months in, Devon wants to swap billhook's hand-rolled order-ID generator for a well-maintained ESM-only package. He adds it to package.json, writes const { generateId } = require("nanoid-ish") at the top of orders.js, and gets:

Error [ERR_REQUIRE_ESM]: require() of ES Module .../node_modules/nanoid-ish/index.js
from .../orders.js not supported. Instead change the require of index.js in
.../orders.js to a dynamic import() which is available in all CommonJS modules.

The package ships as an ES Module — import/export, the standardized syntax browsers use natively, which Node has fully supported for years — and a CommonJS file's require() simply cannot load one directly.

// math.mjs
export function add(a, b) { return a + b; }

// app.mjs
import { add } from "./math.mjs";

What actually decided the trade-off: resolving an ESM import happens asynchronously, up front, which is exactly the property that lets a bundler (or Node itself) figure out ahead of time what's actually used and prune the rest — something require()'s fully dynamic, run-whenever nature makes essentially impossible to reason about statically. import also has to live at a file's top level, hoisted before anything else runs, so there's no reaching for one conditionally inside an if the way require() allows — the closest equivalent is import(), callable from anywhere, which hands back a Promise instead of the module itself. And nothing about ESM needs an opt-in for strict mode; it's simply always on.

How Node decides which system a given file is speaking

SignalResult
File extension .mjsAlways an ES Module
File extension .cjsAlways CommonJS
File extension .js, nearest package.json has "type": "module"ES Module
File extension .js, nearest package.json has "type": "commonjs" or no "type" at allCommonJS (the default)

It's that bottom row of the table that actually tripped Devon up before he even reached the ESM package itself: a plain .js file carries no built-in signal about which module system it belongs to — that call is made entirely by whichever package.json sits closest to it, walking up the folder tree until one is found. billhook's package.json has no "type" field, so every .js file in it defaults to CommonJS, which is exactly why require("nanoid-ish") failed rather than silently doing something unexpected — Node knew orders.js was CommonJS and the package it was pulling in wasn't. Devon's actual fix was const { generateId } = await import("nanoid-ish") inside an async function, the dynamic-import escape hatch mentioned above, rather than converting the whole service to ESM over one dependency.

package.json, trimmed to what billhook actually declares

{
  "name": "billhook",
  "version": "1.3.0",
  "type": "commonjs",
  "main": "server.js",
  "scripts": {
    "start": "node server.js",
    "dev": "nodemon server.js",
    "test": "jest"
  },
  "dependencies": {
    "express": "^4.19.2",
    "pg": "^8.12.0"
  },
  "devDependencies": {
    "nodemon": "^3.1.0",
    "jest": "^29.7.0"
  },
  "engines": { "node": ">=20" }
}

scripts names commands runnable via npm run <name> — start and test get the short forms npm start/npm test; everything else needs the full npm run dev. main is what another package gets back if it ever require()s billhook as a dependency, which nothing currently does, but it's there.

dependencies vs. devDependencies

dependencies are what billhook needs at runtime in production — Express, the Postgres driver, anything actually required by code that runs on the server. devDependencies are only needed while working on it — jest for tests, nodemon for auto-restart during development. This distinction earns its keep at deploy time: npm install --omit=dev skips devDependencies entirely, so the container image Northbound ships doesn't carry a test runner it will never invoke in production.

Semver ranges: ^ and ~

Every npm version number follows MAJOR.MINOR.PATCH, and it's the single character sitting in front of it that decides how far npm install is permitted to wander from that number on its own:

RangeMeaning^8.12.0 allows~8.12.0 allows
^ (caret)Anything that leaves the leftmost non-zero digit alone8.12.1 through anything < 9.0.0—
~ (tilde)Bug-fix-level (patch) bumps only—8.12.1 through anything < 8.13.0
exact (8.12.0)Nothing moves automaticallyonly 8.12.0only 8.12.0

Why ^ by default: npm is leaning on the promise baked into semver itself — that only a major version number is allowed to break you, and minor/patch bumps are supposed to be safe additions or fixes. Pin pg at ^8.12.0 and you're implicitly trusting every maintainer between here and some future 9.0.0 to have kept that promise. Plenty haven't, on any given package, which is precisely the gap package-lock.json closes: it isn't recording ranges, it's recording the literal version numbers npm actually resolved to last time, down to the transitive dependencies nobody chose directly. Commit that file, and npm ci stops asking "what does ^8.12.0 currently mean" and just installs exactly what's written — identical on Ines's laptop, Devon's, and whatever machine actually runs the deploy, independent of anything upstream having moved in the meantime.