Functions, closures and modules
ES Modules, npm and Dependency Hygiene
Named exports, live bindings, dynamic import and CommonJS interop, plus the package.json fields and semver ranges that decide what your next install actually gets.
Modules are where JavaScript mistakes survive longest. A default export reads fine on the day you write it and turns every rename into a search across three packages. A caret range installs something different on your laptop than in CI, and nothing fails until the deploy that matters. Neither problem throws an error where you can see it.
Two problems are tangled together. One is the module system: how exports are named, what an
imported binding means, and when code loads. The other is the package manager on top, which
decides which version of a dependency your node_modules contains. A project can be
right about one and wrong about the other. The examples ran on Node 24.11.1 against a hand-built
node_modules, so the output needs no network.
Named exports survive refactoring and default exports do not
A module exports as many named things as it likes, and one default. Importers ask for named exports by name:
// src/format.mjs
export const CURRENCY = "EUR";
export let locale = "de-DE";
export function money(amount) {
return new Intl.NumberFormat(locale, { style: "currency", currency: CURRENCY }).format(amount);
}
export function setLocale(next) {
locale = next;
}
export default function moneyShort(amount) {
return `${amount.toFixed(2)} ${CURRENCY}`;
}
// src/main.mjs
import short, { money, locale, setLocale } from "./format.mjs";
console.log("money:", money(1234.5));
setLocale("fr-FR");
console.log("money now:", money(1234.5));
console.log("default:", short(2));
money: 1.234,50 €
money now: 1 234,50 €
default: 2.00 EUR
The default export has no name of its own, and that is the whole problem.
moneyShort exists inside the module and nowhere else, so every importer invents a
label. Across a package the same function is called short,
formatMoney and fmt in different files, and when you rename it the
tooling cannot find the callers, because no caller ever wrote the name down. Your editor renames
a named export across a workspace; for a default it can only guess. A default can also be
renamed at the import site with no error, so a change of meaning hides behind a change of
alias:
import short from "./format.mjs"; // the same function
import moneyShort from "./format.mjs"; // still the same function
My rule is one default per package, and only when the package exists to provide one obvious
thing. Defaults do have one advantage: module.exports = fn maps to a default, so
they are convenient for CommonJS interop. That justifies one on a package whose entry point is a
single function, not on a utility module.
An imported name is a live read-only binding
When you write import { locale }, you do not get a copy of the value at the
moment of import. You get a binding to the exporting module's own binding. Reassign it in the
exporter and every importer sees the new value, with no re-import and no getter written by hand.
The output above shows it: setLocale("fr-FR") changed the formatting in a later
money() call in the importing file.
The binding is read-only to the importer, and the error depends on how you try to write it:
import * as fmt from "./format.mjs";
import { locale } from "./format.mjs";
locale = "en-GB";
// TypeError: Assignment to constant variable.
fmt.locale = "en-GB";
// TypeError: Cannot assign to read only property 'locale' of object '[object Module]'
Both are TypeErrors, and the second tells you what a module namespace object is:
a read-only view of the exporting module's bindings. That is why the old spy trick of importing a
namespace and overwriting a function on it fails.
The graph is analysed before anything runs, so a name that does not exist is a link error
rather than a late undefined:
import { nope } from "./format.mjs";
SyntaxError: The requested module './format.mjs' does not provide an export named 'nope'
That is the practical argument for ES modules in new Node code. CommonJS
require resolves a missing property to undefined and fails later at the
call. The price is a static graph: every import resolves before evaluation, in dependency order,
and the syntax has no conditional import.
Dynamic import splits code and postpones evaluation
import() returns a promise for the module namespace. It can appear anywhere, take
a computed specifier, and evaluates the module then rather than at startup:
// src/dynamic.mjs
const name = process.argv[2] ?? "lazy";
console.log("before import");
const mod = await import(`./${name}.mjs`);
console.log("after import:", Object.keys(mod));
console.log("call:", mod.heavy());
before import
lazy.mjs evaluated
after import: [ 'heavy' ]
call: expensive result
The order of that output is the point. The imported module is evaluated inside the
await, so anything it does at import time happens then. In a bundler the module
lands in a separate chunk fetched when the promise runs, which is how a heavy editor stays out
of the initial payload, and it allows conditional loading by environment or feature check. The
promise rejects with a code that says whether the module was missing or threw while
evaluating:
ERR_MODULE_NOT_FOUND: Cannot find module '/tmp/app/nope-does-not-exist.mjs' imported from /tmp/app/[eval]
Two cautions. The catch is not optional, because an unhandled rejection can terminate a Node process. And a computed specifier is opaque to bundlers: Node resolves it against the file system, a browser bundler cannot enumerate what might load, so prefer a static map there.
CommonJS still works, and the default export is module.exports
Most packages you install are CommonJS, so the interop rules matter. Importing one from an
ES module gives you a namespace whose default is module.exports, plus
named exports when the static analyser can see them:
// src/legacy.cjs
const VERSION = "1.4.0";
function slugify(text) { return text.toLowerCase().replace(/[^a-z0-9]+/g, "-"); }
module.exports = { slugify, VERSION };
module.exports.extra = "added later";
import legacy from "./legacy.cjs";
console.log(legacy.slugify("Hello World"), legacy.VERSION);
const ns = await import("./legacy.cjs");
console.log("namespace keys:", Object.keys(ns));
console.log("ns.default === legacy:", ns.default === legacy);
hello-world 1.4.0
namespace keys: [ 'VERSION', 'default', 'extra', 'module.exports', 'slugify' ]
ns.default === legacy: true
Node's loader reads the CommonJS source and infers the named exports, which is why
VERSION and slugify appear alongside default. The
inference is best effort, so when in doubt import the default and destructure from it:
import legacy from "./legacy.cjs";
const { slugify, VERSION } = legacy;
The other direction changed recently enough that older advice is wrong. Calling
require() on an ES module used to throw ERR_REQUIRE_ESM; on the Node
24.11.1 release used here it works and returns the frozen namespace, and a module using top-level
await cannot be loaded that way at all. If a library is consumed from both sides,
ship a CommonJS entry for the require condition rather than relying on interop.
exports in package.json decides what importers may reach
The exports field maps a specifier to a file or a condition, and once a package
declares it, that map is the complete list of what consumers can import:
{
"name": "widgetly",
"version": "2.3.4",
"type": "module",
"main": "./lib/index.js",
"exports": {
".": {
"import": "./lib/index.js",
"require": "./lib/index.cjs"
},
"./plugin": "./lib/plugin.js",
"./package.json": "./package.json"
},
"engines": { "node": ">=22" },
"files": ["lib"]
}
import { api } from "widgetly"; // root: esm entry
await import("widgetly/plugin"); // subpath: true
await import("widgetly/lib/internal.js");
// ERR_PACKAGE_PATH_NOT_EXPORTED
// Package subpath './lib/internal.js' is not defined by "exports" in .../widgetly/package.json
require("widgetly"); // cjs got: cjs entry
Three fields do different jobs. main is the old single-entry pointer, kept for
tools that predate exports, which wins wherever it is present. Its conditions are
matched in order against the loader doing the asking, which is how one specifier resolves to an
ES module for import and a CommonJS file for require, as the last line
of that transcript shows. type changes behaviour at runtime: it sets how
.js files are read, with "module" making them ES modules and the
.mjs and .cjs extensions overriding it either way. Get it wrong and the
error names the fix:
Warning: Failed to load the ES module: /tmp/app/src/type-test.cjs.
Make sure to set "type": "module" in the nearest package.json file or use the .mjs extension.
engines is a declaration, not an enforcement. I set
"engines": { "node": ">=22" } on everything I publish, because npm reports a
mismatch to the installer and CI systems read it, but Node ignores it at runtime. If a check must
happen, compare process.versions.node in your own code. For an application I skip
exports and set "private": true, since encapsulation matters only to
consumers and there are none.
A caret range is a promise about the next install, not about the version
A semver range describes which versions are acceptable, and the caret has one rule with an
exception that catches people on small numbers. ^1.2.3 allows anything at or above
1.2.3 below 2.0.0; ^0.2.3 allows 0.2.x only,
because below 1.0 the minor number is the breaking one; ^0.0.3 allows exactly
0.0.3. These are npm's semantics, taken from the semver package:
const semver = require("semver");
const cases = [
["^1.2.3", ["1.2.3", "1.2.4", "1.3.0", "1.9.9", "2.0.0", "1.2.2", "0.9.0"]],
["^0.2.3", ["0.2.3", "0.2.9", "0.3.0", "0.2.2"]],
["^0.0.3", ["0.0.3", "0.0.4", "0.1.0"]],
["~1.2.3", ["1.2.3", "1.2.9", "1.3.0"]],
["1.2.x", ["1.2.0", "1.2.9", "1.3.0"]],
];
for (const [range, versions] of cases) {
const ok = versions.filter((v) => semver.satisfies(v, range));
const no = versions.filter((v) => !semver.satisfies(v, range));
console.log(range.padEnd(8), "allows:", ok.join(" "), "| rejects:", no.join(" "));
}
^1.2.3 allows: 1.2.3 1.2.4 1.3.0 1.9.9 | rejects: 2.0.0 1.2.2 0.9.0
^0.2.3 allows: 0.2.3 0.2.9 | rejects: 0.3.0 0.2.2
^0.0.3 allows: 0.0.3 | rejects: 0.0.4 0.1.0
~1.2.3 allows: 1.2.3 1.2.9 | rejects: 1.3.0
1.2.x allows: 1.2.0 1.2.9 | rejects: 1.3.0
Read the first line slowly, because it is the whole problem. ^1.2.3 admits
1.9.9 and rejects 2.0.0, and the resolver takes the highest version it
is allowed. The range is therefore not a promise about the version you get but about the ceiling
the resolver may climb to. A dependency author who ships a breaking change in a minor release,
which semver forbids and people do anyway, breaks you on an install you thought was safe. The
exception catches people on small packages: ^0.2.3 admits 0.2.9 and
refuses 0.3.0, tighter than the number suggests if you have only internalised the
1.x rule.
The lockfile pins the version and the package.json pins the range
Lockfiles close that gap: the manifest records the range you asked for, the lockfile the exact version and integrity hash that was resolved.
// package.json
"dependencies": { "semver": "^7.8.5" }
// package-lock.json
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="
}
Install from that pair and you get 7.8.5 even after 7.9.0 ships,
because the resolver honours the lockfile. The integrity field is the part people
skip: it is a hash of the tarball, so a republished or tampered package fails to install instead
of installing quietly. Commit the lockfile even for a library, because it is how your CI
reproduces what you tested, but do not expect it to constrain consumers, who resolve against
your ranges.
Two habits keep this from rotting. Run npm ci in CI rather than
npm install, because ci installs from the lockfile and fails when the
manifest disagrees instead of rewriting the lockfile on the build server. And pin a dependency
exactly, with no caret, when a surprise minor version would cost you a day.
Build a two-file package under node_modules by hand, so you control what the
resolver sees. Create node_modules/gaugekit/package.json with
"name": "gaugekit", "version": "0.4.2",
"type": "module", a main pointing at
./lib/index.js, an exports map with a root entry offering
import and require conditions plus one subpath
./format, and "engines": { "node": ">=22" }. Write
lib/index.js as ES module code, lib/index.cjs as CommonJS, and
lib/format.js that the ./format subpath points at. Add a fourth
file lib/internal.js that no entry mentions.
Then predict the outcome of each line below before you run it, and write down the error code for the ones that fail.
import { gauge } from "gaugekit";
await import("gaugekit/format");
await import("gaugekit/lib/internal.js");
await import("gaugekit/lib/format.js");
await import("gaugekit");
await import("gaugekit/package.json", { with: { type: "json" } });
If gaugekit is at 0.4.2, what does a dependency declaring
"gaugekit": "^0.4.0" permit, and would it accept 0.5.0? And if you
deleted the exports map but kept main, which of the six lines above
change behaviour?
Show the solution and the reasoning
Here is the package as I would write it. The subpath entry is the only thing a consumer can
reach besides the root, and the require condition gives CommonJS callers a real
CommonJS file rather than an interop guess.
{
"name": "gaugekit",
"version": "0.4.2",
"type": "module",
"main": "./lib/index.js",
"exports": {
".": {
"import": "./lib/index.js",
"require": "./lib/index.cjs"
},
"./format": "./lib/format.js",
"./package.json": "./package.json"
},
"engines": { "node": ">=22" }
}
The six lines resolve like this. The root import works through the import
condition, and so does the dynamic one. gaugekit/format works because the map
names it. gaugekit/lib/internal.js and gaugekit/lib/format.js both
fail with ERR_PACKAGE_PATH_NOT_EXPORTED: with a map a package no longer exposes
its files by path, and the exported specifier for that second file is
gaugekit/format. The last line returns the package's own
package.json, which needs the JSON import attribute and is exposed because tools
read its version.
The path-shaped specifiers are the ones people expect to work. Forget the with
attribute on the JSON line and the error is ERR_IMPORT_ATTRIBUTE_MISSING: the
resolver found the file, the loader refused to guess its type. On the range,
^0.4.0 permits 0.4.x only, so it accepts 0.4.2 and
refuses 0.5.0.
Delete exports but keep main and three of the six change. The root
import resolves through main to ./lib/index.js, so it still works,
and CommonJS require("gaugekit") now loads that ES module through the interop path
rather than index.cjs, which means a CommonJS consumer silently changes target.
The gaugekit/format specifier stops resolving with
ERR_MODULE_NOT_FOUND, because a subpath exists only if the map creates it, and
gaugekit/lib/internal.js starts working, because without a map every file is
reachable by path. That last one is why I keep a map on anything published: without it, every
internal file becomes public API the day someone imports it.
Progress
Mark this lesson done and it stays marked in this browser.