JavaScript by Example

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)); // 2

Default 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);    // info

Circular 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.

Enjoyed this? Get more software craft delivered to your inbox.