|$ curl https://forge-ai.dev/api/markdown?path=docs/js/modules
$cat docs/javascript-modules.md
updated July 7, 2026·28 min read·published

JavaScript Modules

JavaScriptIntermediateES6+Intermediate
Introduction

JavaScript modules are a way to split code into separate files, each with its own scope. Before modules were standardized, global variables were the primary mechanism for sharing code across files, which led to naming collisions, implicit dependencies, and fragile code that was difficult to reason about or refactor. The module pattern solves all of these problems by providing explicit boundaries between units of code.

Modules provide three fundamental benefits. First, encapsulation — each module has its own private scope; variables and functions declared inside a module are not visible outside unless explicitly exported. This prevents accidental global pollution and creates clean public APIs. Second, reusability — modules can be imported by any other module, enabling a composable architecture where small, focused modules are combined to build larger systems. Third, dependency management — import/export declarations make dependencies explicit and create a clear dependency graph that tools like bundlers can analyze statically.

Modern JavaScript supports two major module systems: ES Modules (ESM), the official standard integrated into the language specification, and CommonJS (CJS), the module system originally created for Node.js. Understanding both is essential because the JavaScript ecosystem spans browsers, servers, build tools, and package managers, each with its own module conventions.

module-intro.js
JavaScript
1// Before modules: global scope and IIFE patterns
2var globalCounter = 0; // pollutes window
3
4var MyModule = (function () {
5 var privateVar = "secret";
6 return {
7 getSecret: function () { return privateVar; }
8 };
9})();
10
11// With ES modules: clean, explicit, scoped
12// math.js
13export function add(a, b) { return a + b; }
14
15// app.js
16import { add } from "./math.js";
17console.log(add(2, 3)); // 5
📝

note

ES Modules are the official standard and are supported in all modern browsers and Node.js (v12+). CommonJS remains prevalent in the Node.js ecosystem, especially in older packages. For new projects, prefer ESM.
ES Modules

ES Modules (ESM) are the official JavaScript module system defined in ES2015 (ES6). Every module is a file, and the file extension .js or .mjs tells the runtime how to treat it. Modules use the import and export keywords, which are parsed statically (before execution begins), enabling powerful compile-time optimizations.

In the browser, modules are loaded via the <script type="module"> tag. Module scripts are deferred by default, meaning they execute after the HTML is parsed. They also have strict mode enabled by default, and they do not create global variables — every top-level declaration is scoped to the module. Modules are fetched with CORS and respect the same-origin policy.

Module specifiers (the string passed to import) distinguish between bare specifiers (like "lodash") and relative/absolute specifiers (like "./utils.js" or "/lib/helpers.js"). Bare specifiers require an import map or a bundler to resolve. Relative specifiers work natively in browsers.

es-module.html
HTML
1<!DOCTYPE html>
2<html lang="en">
3<head>
4 <meta charset="UTF-8" />
5 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6 <title>ES Module Demo</title>
7 <!-- Module script — deferred, strict, scoped -->
8 <script type="module" src="app.js"></script>
9 <!-- Inline module script -->
10 <script type="module">
11 import { version } from "./config.js";
12 console.log("App version:", version);
13 </script>
14</head>
15<body>
16 <h1>Modules in Browser</h1>
17</body>
18</html>
module-scope.js
JavaScript
1// Modules are singleton scopes
2// config.js
3export const version = "1.0.0";
4export const author = "Blueprint Team";
5const internalToken = "sk-..."; // NOT exported — private
6
7// app.js — imports are hoisted to the top of the module
8import { version, author } from "./config.js";
9
10console.log(version); // "1.0.0"
11console.log(author); // "Blueprint Team"
12console.log(internalToken); // ReferenceError: not accessible
Named Exports

Named exports allow a module to export multiple values, each with a specific name. There are two syntaxes: inline exports, where the export keyword is placed directly before a declaration, and bottom-of-file exports, where a single export { ... } statement lists all exports at the end of the module. Both approaches produce the same result, but the bottom-of-file pattern makes it easier to see the complete public API at a glance.

When importing named exports, you destructure them using curly braces. You can also rename imports using the as keyword to avoid naming conflicts or to provide shorter aliases. Named exports are the preferred way to expose utility functions, constants, and multiple values from a module.

Imported named exports are live bindings, not copies. If the exporting module changes the value, the importing module sees the change. However, importing modules cannot reassign exported bindings (they are read-only views).

named-exports.js
JavaScript
1// ─── Inline exports ───
2
3// operations.js
4export const PI = 3.14159;
5
6export function circleArea(radius) {
7 return PI * radius * radius;
8}
9
10export class Calculator {
11 static add(a, b) { return a + b; }
12}
13
14// ─── Bottom-of-file exports (same result) ───
15
16// utils/math.js
17const PI = 3.14159;
18
19function circleArea(radius) {
20 return PI * radius * radius;
21}
22
23function circumference(radius) {
24 return 2 * PI * radius;
25}
26
27export { PI, circleArea, circumference };
28
29// ─── Importing named exports ───
30
31// app.js
32import { PI, circleArea, Calculator } from "./operations.js";
33import { circumference as circ } from "./utils/math.js";
34
35console.log(circleArea(5)); // 78.53975
36console.log(circ(5)); // 31.4159
37console.log(Calculator.add(2, 3)); // 5
38
39// ─── Import all named exports as a namespace ───
40
41import * as MathUtils from "./utils/math.js";
42console.log(MathUtils.PI); // 3.14159

best practice

Prefer named exports for most use cases. They make it clear exactly what a module provides, enable tree-shaking, and work well with IDE autocompletion. Use bottom-of-file exports when you want to clearly document the module's public API in one place.
Default Exports

A module can have at most one default export. Default exports are typically used when the module represents a single main concept — a class, a primary function, or a component. The syntax uses export default followed by the value (which can be an expression, so the declaration does not need a name).

When importing a default export, the curly braces are omitted and you can assign any name to the import. This is convenient but can lead to inconsistent naming across a codebase. Default exports cannot be tree-shaken because the bundler cannot know which properties of the default export are used at compile time.

It is possible to mix named exports with a default export in the same module. The default export is effectively a named export with the special name default.

default-exports.js
JavaScript
1// ─── Default export (function) ───
2
3// greeter.js
4export default function greet(name) {
5 return "Hello, " + name + "!";
6}
7
8// ─── Default export (class) ───
9
10// user.js
11export default class User {
12 constructor(name, email) {
13 this.name = name;
14 this.email = email;
15 }
16}
17
18// ─── Default export (anonymous expression) ───
19
20// logger.js
21export default {
22 log(msg) { console.log("[LOG]", msg); },
23 error(msg) { console.error("[ERR]", msg); },
24};
25
26// ─── Importing default exports ───
27
28import greet from "./greeter.js";
29import User from "./user.js";
30import logger from "./logger.js";
31
32console.log(greet("World"));
33
34const u = new User("Alice", "alice@example.com");
35
36// ─── Mixing default and named ───
37
38// api.js
39export default function request(url) { ... }
40export const BASE_URL = "https://api.example.com";
41export function parseResponse(data) { ... }
42
43// app.js
44import request, { BASE_URL, parseResponse } from "./api.js";
45
46// ─── The default export is just "default" behind the scenes ───
47export { request as default, BASE_URL, parseResponse };
48
49// ─── Re-exporting defaults ───
50export { default as Greeter } from "./greeter.js";
🔥

pro tip

Default exports are controversial. Some style guides recommend avoiding them entirely in favor of named exports for consistency. The key argument is that named exports enforce a single name everywhere, while default imports can be renamed arbitrarily by each consumer, making grepping harder.
Module Scope

One of the most important properties of ES modules is that they execute in strict mode by default. You do not need to write "use strict" at the top — it is implied. Strict mode changes several language semantics: silent errors become thrown errors, assignments to non-writable globals throw, and the this keyword in top-level functions is undefined instead of the global object.

Module scope means that every top-level declaration (variables, functions, classes) is private to the module. No implicit globals are created. In a non-module script, var x = 1 at the top level creates a property on window. In a module, it creates a module-scoped variable that is invisible to other modules unless explicitly exported.

The top-level this in a module is undefined, not window. This is a common gotcha when refactoring old code into modules. If you need to access the global object, use globalThis, which works in all contexts (modules, workers, Node.js).

module-scope.js
JavaScript
1// module-scope.js — runs as an ES module
2"use strict"; // IMPLICIT — no need to write this
3
4// No implicit globals
5var x = 42; // module-scoped, NOT on window
6let y = "hello"; // module-scoped
7const z = true; // module-scoped
8
9// Top-level this is undefined
10console.log(this); // undefined (not window)
11
12// globalThis works everywhere
13console.log(globalThis); // the global object
14
15// Strict mode differences:
16// 1. Silent errors become thrown
17// 2. Octal literals (0777) are syntax errors
18// 3. with() is forbidden
19// 4. NaN = 1 throws instead of silently failing
20// 5. Functions must be declared at top level only
21
22function strictTest() {
23 // Cannot access the global object through 'this'
24 console.log(this); // undefined
25}
26
27// Private to module — not visible to other files
28const internalConfig = {
29 debug: true,
30 logLevel: "info",
31};
32
33// Only exported bindings are visible externally
34export const publicConfig = {
35 version: "1.0.0",
36};

warning

In a module, this at the top level is undefined. If you rely on this referring to window (e.g., this.setTimeout(...)), refactor to use the global reference explicitly or use globalThis.
Dynamic Imports

Static import declarations are evaluated before the module body executes and can only appear at the top level of a module. Dynamic imports, using the import() function, allow you to load modules on demand at runtime. The import() function returns a promise that resolves to the module namespace object.

Dynamic imports enable several important patterns. Lazy loading — loading code only when it is needed, reducing initial bundle size and improving page load performance. Code splitting — bundlers like webpack, Rollup, and Vite use dynamic import boundaries to split output into separate chunks that are loaded on demand. Conditional loading — importing different modules based on runtime conditions like user preferences, feature flags, or device capabilities.

Dynamic imports work in both module and non-module scripts. They are also supported in Node.js for both ESM and CJS contexts (though in CJS, import() is the only way to load ESM modules).

dynamic-imports.js
JavaScript
1// ─── Dynamic import returns a promise ───
2
3// Basic lazy loading
4const modulePath = "./heavy-library.js";
5
6import(modulePath)
7 .then((module) => {
8 module.doSomething();
9 })
10 .catch((err) => {
11 console.error("Failed to load module:", err);
12 });
13
14// ─── Using async/await ───
15
16async function loadDashboard() {
17 try {
18 const { renderChart, formatData } = await import("./dashboard.js");
19 const data = await fetch("/api/stats");
20 renderChart(formatData(data));
21 } catch (err) {
22 showFallbackUI();
23 }
24}
25
26document.getElementById("dashboard-btn")
27 .addEventListener("click", loadDashboard);
28
29// ─── Conditional loading ───
30
31async function loadFormatter(locale) {
32 if (locale === "de-DE") {
33 return import("./formatters/de.js");
34 } else if (locale === "ja-JP") {
35 return import("./formatters/ja.js");
36 }
37 return import("./formatters/en.js");
38}
39
40// ─── Dynamic import with default export ───
41
42const { default: ChartLib } = await import("./chart-library.js");
43
44// ─── Code splitting boundary example (bundler) ───
45
46// routes.js (conceptual with React Router)
47const routes = [
48 { path: "/", component: () => import("./pages/Home.js") },
49 { path: "/settings", component: () => import("./pages/Settings.js") },
50 { path: "/admin", component: () => import("./pages/Admin.js") },
51];

info

Dynamic imports are the foundation of code splitting. Every import() call becomes a separate chunk in most bundlers. Use them for route-level splitting (one chunk per route) or for conditionally large libraries like charting, date formatting, or markdown parsing.
CommonJS vs ESM

CommonJS (CJS) is the module system originally designed for Node.js, using require() and module.exports. It is synchronous, which is fine for server-side loading from disk but not suitable for browsers. ESM is the official ECMAScript standard, supports both sync and async loading, and can be statically analyzed.

The two systems differ in many fundamental ways: when they are evaluated, how bindings work, whether they support tree-shaking, and how circular dependencies are handled. Understanding the differences is crucial when working in hybrid environments like Node.js with mixed ESM/CJS packages.

FeatureCommonJS (CJS)ES Modules (ESM)
Syntaxrequire()import / export
LoadingSynchronousAsync (static + dynamic)
EvaluationRuntimeParse-time (static analysis)
BindingsCopied values (primitives), reference for objectsLive bindings (read-only views)
Tree-shakingNot supportedSupported (static exports)
Top-level thismodule.exportsundefined
Strict modeOpt-inDefault
Circular depsPartial exports (may get undefined)Live bindings (always up-to-date)
File extension.js / .cjs.js / .mjs
Browser supportBundler onlyNative
Dynamic loadingrequire() inlineimport() function
Named exportsexports.foo = ...export { foo }
cjs-vs-esm.js
JavaScript
1// ─── CommonJS (CJS) ───
2
3// utils.js — exporting
4const PI = 3.14159;
5
6function add(a, b) { return a + b; }
7
8module.exports = {
9 PI,
10 add,
11};
12// Or: exports.PI = PI; exports.add = add;
13
14// app.js — importing
15const { PI, add } = require("./utils.js");
16
17console.log(add(PI, 2)); // 5.14159
18
19// ─── ES Module (ESM) ───
20
21// utils.js — exporting
22export const PI = 3.14159;
23export function add(a, b) { return a + b; }
24
25// app.js — importing
26import { PI, add } from "./utils.js";
27
28console.log(add(PI, 2)); // 5.14159
29
30// ─── Key behavioral difference: bindings ───
31
32// counter.cjs
33let count = 0;
34module.exports = { count, increment: () => count++ };
35
36// app.cjs — count is a COPY at require time
37const { count, increment } = require("./counter.cjs");
38increment();
39increment();
40console.log(count); // 0 — still the original copy!
41
42// counter.mjs
43export let count = 0;
44export const increment = () => count++;
45
46// app.mjs — count is a LIVE BINDING
47import { count, increment } from "./counter.mjs";
48increment();
49increment();
50console.log(count); // 2 — live binding, always current

danger

Mixing CJS and ESM in the same project can be tricky. ESM can import CJS modules (the default export is always available), but CJS cannot use require() to load ESM modules — you must use import() (dynamic import) instead. In Node.js, this is enforced by the module system and will throw an error.
Tree Shaking

Tree shaking is a dead-code elimination technique performed by bundlers (webpack, Rollup, Vite, esbuild). It analyzes the static structure of ES modules — specifically, the import and export declarations — to determine which exports are actually used by the application. Any exports that are never imported can be safely removed from the final bundle, reducing file size.

Tree shaking works because ES module syntax is static. The import and export keywords must appear at the top level of a module and cannot be wrapped in conditionals or loops. This means the bundler can determine the dependency graph without executing any code — it just parses the AST (Abstract Syntax Tree).

The concept of side effects is critical for tree shaking. If a module has side effects (code that runs on import, like modifying globals, setting up polyfills, or registering custom elements), the bundler cannot safely remove it even if no exports are used. Package authors use the "sideEffects": false field in package.json to declare that their package is side-effect-free, enabling more aggressive tree shaking.

tree-shaking.js
JavaScript
1// ─── Tree-shakeable module ───
2
3// utils.js — multiple named exports
4export function add(a, b) { return a + b; }
5export function subtract(a, b) { return a - b; }
6export function multiply(a, b) { return a * b; }
7export function divide(a, b) { return a / b; }
8
9// app.js — only imports add, ignoring the rest
10import { add } from "./utils.js";
11console.log(add(2, 3)); // 5
12// subtract, multiply, divide are tree-shaken away
13
14// ─── What PREVENTS tree shaking ───
15
16// 1. Side effects at module level
17// bad-effects.js
18console.log("Module loaded!"); // side effect!
19Array.prototype.customMethod = function () {}; // side effect!
20export const data = [1, 2, 3];
21
22// 2. Dynamic property access on exports
23// The bundler cannot know which key is accessed at compile time
24import * as utils from "./utils.js";
25const method = "add";
26utils[method](2, 3); // prevents tree-shaking of ALL exports
27
28// 3. Default exports on objects (cannot be analyzed)
29export default {
30 foo: () => {},
31 bar: () => {},
32};
33import lib from "./library.js";
34lib.foo(); // bundler cannot know bar is unused
35
36// ─── Side effects in package.json ───
37
38// package.json
39{
40 "name": "my-library",
41 "sideEffects": false,
42 // or specify files with side effects:
43 "sideEffects": [
44 "./polyfills.js",
45 "*.css"
46 ]
47}

best practice

To maximize tree shaking: use named exports instead of default exports, avoid side effects at module level, use import { specific } from "package" instead of import *, and ensure library authors set "sideEffects": false in their package.json.
Circular Dependencies

A circular dependency occurs when two or more modules depend on each other: module A imports from B, and B imports (directly or transitively) from A. In well-designed systems, circular dependencies are rare and often indicate that modules should be refactored or that a shared dependency should be extracted. However, they do sometimes arise in practice, and it is important to understand how each module system handles them.

In CommonJS, when a circular dependency is encountered, require() returns whatever has been assigned to module.exports at the time the circular call happens. If module B's exports are not yet fully populated, the importing module may get an incomplete object or undefined for certain properties. This is the source of many subtle bugs.

ES Modules handle circular dependencies more gracefully through live bindings. Because exports are live bindings rather than copies, even if module B is still being evaluated when module A references its exports, the binding remains connected. Once module B finishes evaluating, the values become available. This means ESM circular dependencies are more likely to work correctly, but they still require careful design.

circular-deps.js
JavaScript
1// ─── Circular dependency in CJS (problematic) ───
2
3// a.js
4const b = require("./b.js");
5console.log("a.js: b.message =", b.message);
6module.exports = { message: "Hello from A" };
7
8// b.js
9const a = require("./a.js"); // a is partially loaded!
10// a.message is undefined at this point!
11console.log("b.js: a.message =", a.message);
12module.exports = { message: "Hello from B" };
13
14// main.js
15const a = require("./a.js");
16// Output:
17// b.js: a.message = undefined ← incomplete
18// a.js: b.message = Hello from B
19// console.log(a.message) → Hello from A
20
21// ─── Circular dependency in ESM (live bindings) ───
22
23// a.mjs
24import { message as bMsg } from "./b.mjs";
25console.log("a.mjs: b.message =", bMsg);
26export const message = "Hello from A";
27
28// b.mjs
29import { message as aMsg } from "./a.mjs";
30// bMsg is a LIVE BINDING — it will be resolved
31console.log("b.mjs: a.message =", aMsg);
32export const message = "Hello from B";
33
34// main.mjs
35import { message } from "./a.mjs";
36// Output:
37// b.mjs: a.message = undefined ← not yet initialized
38// a.mjs: b.message = Hello from B
39// console.log(message) → Hello from A

warning

Circular dependencies are a design smell. They indicate that two modules are too tightly coupled. The fix is usually to extract the shared dependency into a third module that both can import. However, if you must have circular deps, prefer ESM over CJS — the live bindings make them more predictable.
Best Practices

One Default Export Per Module

A module should have a single clear responsibility. If it has a default export, that default should represent the primary purpose of the module. Additional utility functions can be named exports alongside a default, but if a module has more than a few exports it might be doing too much.

Use Named Exports for Utilities

Utility functions, constants, types, and helper values should be named exports. This enables tree-shaking, makes it obvious what is available, and forces consumers to explicitly name what they want. Named exports also create a consistent pattern across your codebase.

Avoid Side Effects in Modules

Side effects at module load time — console.log, DOM manipulation, modifying globals, patching prototypes — prevent tree-shaking and make modules unpredictable. Keep module initialization pure. If side effects are required (like polyfills or CSS imports), isolate them in dedicated files and declare them in "sideEffects" in package.json.

Barrel Files

A barrel file is an index module that re-exports selected exports from multiple modules in a directory, providing a single import point for consumers. Barrel files simplify imports but can create circular dependencies and hurt tree-shaking if not used carefully.

Modern guidelines suggest using barrel files sparingly. Prefer direct imports when possible. If you use barrels, re-export only what is necessary and avoid deeply nested barrels. Some tools now even recommend against barrels because they can confuse the module graph.

best-practices.js
JavaScript
1// ─── Barrel file pattern ───
2
3// components/index.js — barrel file
4export { Button } from "./Button.js";
5export { Card } from "./Card.js";
6export { Modal } from "./Modal.js";
7export { Navbar } from "./Navbar.js";
8
9// Consumer imports from the barrel
10import { Button, Card, Modal } from "./components";
11
12// ─── Prefer direct imports in application code ───
13import { Button } from "./components/Button.js";
14
15// ─── Recommended module structure ───
16
17// calculator.js — one clear responsibility
18export function add(a, b) { return a + b; }
19export function subtract(a, b) { return a - b; }
20
21// No side effects at module level
22// No global state mutation
23// Each function is pure and testable
24
25// app.js — explicit imports
26import { add, subtract } from "./calculator.js";
27
28// ─── What to avoid ───
29
30// ❌ Side effects at module level
31// setup.js
32console.log("Setting up..."); // side effect!
33window.myLib = {}; // side effect!
34
35// ❌ Reassigning exports (not possible in ESM)
36// export let count = 0;
37// setTimeout(() => count = 5, 1000); // allowed for let
38
39// ❌ This pattern hides the dependency tree
40import * as Everything from "./lazy-barrel.js";
41// The bundler cannot tree-shake this

best practice

The golden rule: make imports explicit and exports narrow. A module should export only what is necessary for its intended use. Every additional export is a commitment to maintain that API surface. Fewer exports mean simpler contracts, better tree-shaking, and less cognitive load.
Live Demo: Modules in Action

The following live preview demonstrates a complete module-based application rendered in a single HTML page using <script type="module">. The embedded JavaScript defines modules inline to show real import/export behavior, including named exports, default exports, and dependency chaining.

preview
$Blueprint — Engineering Documentation·Section ID: JS-05·Revision: 1.0