JavaScript Modules

6 minute read

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": false in package.json to opt out)
  • Using import * as ns heavily

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 Node package.json
  • ✅ Use import.meta.url instead 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.

← Previous: JS Inheritance Next: JS Regular Expressions →

Updated: