Modules
ES module import/export, default vs named exports, circular imports, and CJS interop.
ES modules give JavaScript first-class support for splitting code across files. Each file is its own module with a private scope - only what you explicitly export is visible to importers.
Named exports are the workhorse of ES modules. A file can export any number of named bindings; importers pick what they need. Names are fixed - renaming at the import site uses as.
// math.js
export function add(a, b) { return a + b; }
export function multiply(a, b) { return a * b; }
export const PI = 3.14159;
// main.js
import { add, PI } from "./math.js";
import { multiply as mul } from "./math.js";
console.log(add(2, 3)); // 5
console.log(PI); // 3.14159
console.log(mul(4, 5)); // 20
// Import everything under a namespace
import * as math from "./math.js";
console.log(math.add(1, 1)); // 2Default exports let a module export one primary value. Importers choose the local name freely - no braces required. A module can have both a default export and named exports.
// logger.js
export default function log(msg) {
console.log(`[LOG] ${msg}`);
}
export const LOG_LEVEL = "info";
// app.js
import log, { LOG_LEVEL } from "./logger.js";
// The importer chose "log" - could have been "writeLog", "print", anything
log("server started"); // [LOG] server started
console.log(LOG_LEVEL); // infoCircular imports - where A imports B and B imports A - are allowed by the spec, but reading a binding before its module has finished initialising throws a ReferenceError. The fix is to extract the shared code into a third module, or move the read behind a function call so it happens after initialisation.
// a.js
import { b } from "./b.js";
export const a = "a";
console.log("in a, b is:", b); // "b" - b.js finished evaluating before this line
// b.js
import { a } from "./a.js";
export const b = "b";
console.log("in b, a is:", a); // ReferenceError: Cannot access 'a' before initialization
// The order matters and is hard to predict at scale.
// Solution: move shared exports to shared.js and import from there.CJS/ESM interop: Node.js defaults to CommonJS (require). To use ES modules, either name the file .mjs or add "type": "module" to package.json. Dynamic import() works in both module systems and is the bridge when you need to load an ESM module from CJS code.
// package.json: { "type": "module" } - all .js files are ESM
// Static import - resolved at parse time, hoisted, cannot be conditional
import { add } from "./math.js";
// Dynamic import - returns a Promise, works anywhere, can be conditional
async function loadPlugin(name) {
if (name === "math") {
const { add } = await import("./math.js");
return add;
}
}
// In a CJS file (.cjs or without "type":"module"), use dynamic import
// to load ESM-only packages
async function main() {
const { default: chalk } = await import("chalk"); // chalk v5+ is ESM-only
console.log(chalk.green("hello"));
}In production
Default exports look convenient but they hurt refactor safety in shared code: the import name is chosen by the importer, not the author, so automated renames don't propagate and static analysis tools can't reliably track usage. A library that exports default function process() will be imported as doThing, handleIt, and runProcess across a large codebase - you lose the single source of truth that named exports provide. Reserve default exports for app-level files where one file represents one feature or page; use named exports everywhere else. Most team style guides (Airbnb, Google) enforce this and so do many ESLint rules.