JavaScript Modules
ES modules (ESM) are how modern JavaScript is split across files. Every file is its own scope, you explicitly export what others can use and import what you need — no globals, no order-of-script-tags fragility. This chapter covers named vs default exports, re-exports, dynamic import(), tree-shaking, and the differences from the older CommonJS (require).
Enabling Modules
<!-- Browser -->
<script type="module" src="./main.js"></script>
// Node — package.json
{ "type": "module" } // .js files are ESM
// or use .mjs extension explicitly
Modules are always in strict mode, deferred by default, and each file is its own scope — no top-level var leaks to window/global.
Named Exports
Export by name, import by the same name:
// math.js
export const PI = 3.14159;
export function square(n) { return n * n; }
export class Vector { /* … */ }
// app.js
import { PI, square, Vector } from "./math.js";
console.log(square(PI)); // Output: 9.86960…
Rename at the import site if you like:
import { square as sq } from "./math.js";
Bulk-export at the bottom is also common:
// math.js
const PI = 3.14159;
function square(n) { return n * n; }
class Vector {}
export { PI, square, Vector };
Default Exports
Each module can have one default — often used when the module is a single thing:
// User.js
export default class User {
constructor(name) { this.name = name; }
}
import User from "./User.js"; // any name you want
import Customer from "./User.js"; // also works — it's the default
Mix-and-match is allowed:
// api.js
export default function request() { /* … */ }
export const VERSION = "1.0";
import request, { VERSION } from "./api.js";
Named vs Default — Pick One Style
| Named | Default | |
|---|---|---|
| Per file | Many | One |
| Rename at import | { a as b } |
Any local name |
| Refactor safety | High (typo = error) | Low (typo = new local var) |
| Best for | Utility collections, types | Module = one thing |
Tip: Prefer named exports for everything. They survive renames better, are easier to grep, and tree-shake more reliably. Reserve default for components, single classes, or single functions.
Re-exports — Barrel Files
Aggregate a folder into one entry point:
// utils/index.js
export { square, cube } from "./math.js";
export { capitalize } from "./string.js";
export * from "./array.js"; // re-export everything named
export { default as User } from "./User.js";
import { square, capitalize, User } from "./utils"; // /index.js implicit
Warning: Big barrel files can hurt tree-shaking in some bundlers — they pull in the whole module graph even if you only use one symbol. Prefer deep imports (./utils/math.js) in performance-critical builds.
Imports Are Live Bindings
Imported names are read-only views of the exporter’s variables, not copies:
// counter.js
export let count = 0;
export function inc() { count++; }
// app.js
import { count, inc } from "./counter.js";
console.log(count); // 0
inc();
console.log(count); // 1 ← live, updates with module state
count = 99; // ❌ TypeError — read-only binding
That’s different from CommonJS, where require returns a snapshot of module.exports.
Dynamic import()
Static import statements run at parse time. Need to load on demand?
button.addEventListener("click", async () => {
const { Chart } = await import("./chart.js");
new Chart("#canvas").draw();
});
import() returns a promise resolving to the module namespace. Use it for:
- Code splitting — only load chunks when needed
- Conditional features — load polyfills only when missing
- Plugin loading — discover module paths at runtime
const lib = isNode ? await import("./node-impl.js") : await import("./web-impl.js");
lib.run();
Namespace Imports — import * as
Grab everything under one object:
import * as math from "./math.js";
math.square(4);
math.PI;
Useful when there are many exports or you don’t know names up front. Bundlers usually have to bring in the whole module — so this is the least tree-shakable style.
Side-Effect Imports
Run a module purely for its side effects (e.g., polyfills, CSS-in-JS, registrations):
import "./polyfills.js";
import "./register-shortcuts.js";
No bindings — just the module body executes.
ESM vs CommonJS — Quick Reference
ESM (import/export) |
CommonJS (require/module.exports) |
|
|---|---|---|
| Loading | Async, parsed-then-executed | Sync, executed on require |
| Scope | Per file (no globals leak) | Per file |
| Bindings | Live | Snapshot |
Top-level await |
✅ | ❌ |
| Tree-shakable | ✅ (static structure) | ❌ (dynamic) |
| Conditional imports | await import() |
if (…) require() |
__dirname / __filename |
Use import.meta.url |
Built-in |
Modern projects should be ESM end-to-end. Use CommonJS only when forced (legacy Node tooling).
import.meta
A module-scoped object with metadata:
console.log(import.meta.url);
// e.g. "file:///Users/me/proj/app.js"
// Node helpers
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
Bundlers also expose env-style fields like import.meta.env (Vite) — check your tool.
Top-Level Await
Inside an ES module you can await at the top level:
// config.js
const res = await fetch("/config.json");
export const config = await res.json();
The module simply waits to finish loading before any importer’s code runs. Use sparingly — anything importing this module is paused until it resolves.
Tree-Shaking — Why Static Matters
Because import/export are static (names known at parse time), bundlers can drop unused exports:
// utils.js
export const a = () => {};
export const b = () => {}; // tree-shaken if no one imports it
// app.js
import { a } from "./utils.js";
Things that break tree-shaking:
- Re-exporting via dynamic
import() - Side effects in module top-level code (mark with
"sideEffects": falseinpackage.jsonto opt out) - Using
import * as nsheavily
A Realistic Example — A Small Toolkit
toolkit/
├── index.js ← barrel
├── http.js
├── format.js
└── retry.js
// toolkit/http.js
export async function getJSON(url) {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
// toolkit/retry.js
export async function retry(fn, { attempts = 3, delay = 200 } = {}) {
let lastErr;
for (let i = 0; i < attempts; i++) {
try { return await fn(); } catch (e) { lastErr = e; await new Promise(r => setTimeout(r, delay * 2 ** i)); }
}
throw lastErr;
}
// toolkit/index.js
export { getJSON } from "./http.js";
export { format } from "./format.js";
export { retry } from "./retry.js";
// app.js
import { retry, getJSON } from "./toolkit";
const data = await retry(() => getJSON("/api/data"));
Common Mistakes
// ❌ Missing the file extension in browser ESM
import { x } from "./utils"; // → 404; needs "./utils.js"
// ❌ Mixing default and named with the wrong syntax
import User, x from "./mod.js"; // ❌ — needs braces around x
// ❌ Importing inside a function and expecting hoisting
function go() { import { x } from "./m.js"; } // ❌ — only top-level statics
// ❌ Reassigning an imported binding
import { count } from "./c.js";
count = 1; // TypeError
// ❌ CommonJS-style require in an ESM file
const fs = require("fs"); // ReferenceError — use import
Best Practices Checklist
- ✅ Prefer named exports over default — better refactor and tree-shake
- ✅ Always include file extensions for browser ESM (
./file.js) - ✅ Use
import()(dynamic) for code-splitting and on-demand features - ✅ Use barrel files for ergonomics — but watch tree-shaking
- ✅ Set
"type": "module"in Nodepackage.json - ✅ Use
import.meta.urlinstead of legacy__dirname - ❌ Don’t mix CJS and ESM in the same project without a clear boundary
- ❌ Don’t put side effects at module top level unless intended
- ❌ Don’t reassign imported bindings — they’re read-only
What’s Next?
Your code is organised into proper modules. Now a power-tool every JS programmer eventually needs: regular expressions — for matching, validating, extracting, and replacing text.