Modules
How JavaScript code is split across files — and the CommonJS/ESM split that still causes more confusion than anything else in the ecosystem.
A module is a file whose top-level declarations are private unless exported.
export const TAX_RATE = 0.2;
export function total(items) {
return items.reduce((sum, i) => sum + i.priceCents, 0);
}
function internalHelper() {} // not exported — invisible outside this fileimport { total, TAX_RATE } from "./pricing.js";
console.log(total(items) * (1 + TAX_RATE));Import forms#
import { total } from "./pricing.js"; // named
import { total as cartTotal } from "./pricing.js"; // renamed
import * as pricing from "./pricing.js"; // everything as a namespace
import formatter from "./format.js"; // the default export
import formatter, { currency } from "./format.js"; // both
import "./side-effects.js"; // run it, import nothingNamed over default#
export default function parse() {} // works, and prefer not to
export function parse() {} // betterNamed exports are worth defaulting to. The name is fixed at the source, so every import uses the same word, rename refactors work across the codebase, and editor auto-import is reliable. A default export can be imported under any name, which is how one function ends up called three things.
Dynamic import#
import() is a function that returns a promise, so it can run conditionally and at runtime:
if (user.wantsCharts) {
const { renderChart } = await import("./charts.js");
renderChart(data);
}The chart library is only downloaded and parsed if it is needed. This is the mechanism behind code splitting, and on the server it is how you avoid paying startup cost for a path most requests never take.
ESM and CommonJS#
The long-running source of confusion. Node originally had its own module system, CommonJS, and later gained the standard one, ESM.
// CommonJS — the old system
const { total } = require("./pricing");
module.exports = { total };
// ESM — the standard
import { total } from "./pricing.js";
export { total };Which one a .js file uses is decided by the nearest package.json:
{ "type": "module" } // .js files are ESM
{ "type": "commonjs" } // .js files are CommonJS (the default if absent)You can also be explicit per file: .mjs is always ESM, .cjs is always CommonJS.
Use ESM for anything new. It is the standard, it works in browsers, it supports top-level await, and the ecosystem has largely moved.
The differences that bite#
| CommonJS | ESM | |
|---|---|---|
| Loading | synchronous, at runtime | asynchronous, statically analysed |
__dirname | available | use import.meta.dirname |
Top-level await | no | yes |
| Importing the other | can require CJS only | can import CJS, with caveats |
| Conditional import | require() anywhere | await import() |
// ESM equivalents of the CommonJS globals
import.meta.dirname // __dirname
import.meta.filename // __filename
import.meta.url // the module's URLA CommonJS file cannot require() an ESM module. That is the source of "this package is ESM-only" — the fix is to move your project to ESM, or use await import() from an async context.
Circular imports#
// a.js
import { b } from "./b.js";
export const a = () => b();
// b.js
import { a } from "./a.js";
export const b = () => a();ESM handles cycles better than CommonJS, but they still produce partially-initialised modules and confusing undefined errors. A cycle almost always means two modules share a concept that wants extracting into a third.
Import maps and bare specifiers#
import { total } from "./pricing.js"; // relative — a file path
import express from "express"; // bare — resolved from node_modulesIn the browser, bare specifiers need an import map:
<script type="importmap">
{ "imports": { "lodash-es": "https://cdn.example.com/lodash-es/lodash.js" } }
</script>Exercise#
// Split this into modules:
// math.js — exports add and multiply (named)
// format.js — exports a default currency formatter
// main.js — imports both, and dynamically imports a "stats.js"
// module only when an array has more than 10 items
console.log("start");Common questions#
Why does my import fail without a file extension?#
ESM requires the full specifier, including .js. CommonJS guessed at extensions and index files and ESM deliberately does not, because the resolution has to be statically analysable. Bundlers hide this, which is why it appears only when Node runs the file directly.
Should I use default or named exports?#
Named. Fixed names mean consistent imports, working rename refactors and reliable auto-import. Reserve the default export for a module with one obvious primary thing, and even then a named export costs nothing.
How do I use an ESM-only package from CommonJS?#
You cannot require() it. Either convert your project to ESM by adding "type": "module", or use await import() from inside an async function. The first is the real fix; the second is a workaround for a file you cannot convert yet.
Get the JavaScript agent pack
A battle-tested AGENTS.md, the review checklist, and the failure-mode cheat sheet for JavaScript. One email, then occasional updates when the tooling shifts. No course pitch.
AGENTS.md now — no email needed.