Original notes for JavaScript360.org — topic order inspired by Eloquent JavaScript (4th ed.) by Marijn Haverbeke. See the full attribution note.
Chapter 10: Modules
Estimated time: ~30 minutes · Chapter type: Concept
What you'll learn
- Why every non-trivial program gets split into modules
- ES module
import/exportsyntax, the modern standard - CommonJS (
require/module.exports), which you'll still see in Node.js code - What a bundler actually does, and why you need one for the browser
Why modular programs
Once a program grows past a few hundred lines, keeping it all in one file becomes unmanageable: everything can accidentally depend on everything else, names collide, and it's impossible to reuse a piece of logic without dragging in the whole file. Modules split a program into separate files, each with:
- Its own private scope (nothing leaks out by accident)
- An explicit, deliberate list of what it exports — the only things other files can use
- An explicit list of what it imports from other modules
This gives you the same benefit functions gave you within a single file (Chapter 3) — clear boundaries and named interfaces — but at the scale of an entire application.
ES modules
The modern, standardized module system (ESM) uses export and import keywords directly in your JavaScript:
// math.js
export function add(a, b) {
return a + b;
}
export function subtract(a, b) {
return a - b;
}
export const PI = 3.14159;
export default function multiply(a, b) { // a module may have one "default" export
return a * b;
}
// app.js
import multiply, { add, subtract, PI } from './math.js';
add(2, 3); // 5
multiply(4, 5); // 20
A few rules worth internalizing:
- Named exports (
export function add) are imported with matching curly-brace names:import { add } from .... - Default exports (
export default ...) are imported without curly braces, and you can name them anything on the importing side:import multiplyThings from './math.js'would work identically. - You can rename an import to avoid a name clash:
import { add as addNumbers } from './math.js'. import * as math from './math.js'grabs everything as one namespace object:math.add(2, 3).
In the browser, you opt into ES modules with <script type="module" src="app.js"></script>; in Node.js, either name the file .mjs, or set "type": "module" in package.json.
Packages
A package is a reusable module (or collection of modules) published so other projects can depend on it, most commonly distributed through npm, JavaScript's dominant package registry. npm install some-package downloads it into your project's node_modules folder and records the dependency in package.json; from there, you import from it exactly like a local file:
import { format } from 'date-fns';
The npm ecosystem is enormous — before writing a non-trivial utility yourself, it's often worth checking whether a well-maintained package already solves the problem.
CommonJS modules
Before ES modules were standardized, Node.js used its own system, CommonJS, which you'll still encounter constantly in older code and some Node tooling:
// math.js (CommonJS)
function add(a, b) {
return a + b;
}
module.exports = { add };
// app.js (CommonJS)
const { add } = require('./math.js');
add(2, 3); // 5
The key practical differences: CommonJS's require is a normal function call that can happen conditionally or dynamically anywhere in your code, while ES module import statements are always at the top of the file and resolved before any code runs (which is what lets tools statically analyze your dependency graph without executing anything — useful for the bundling and tree-shaking discussed next). Modern Node.js supports both systems, but new projects should default to ES modules.
Building and bundling
Browsers can load ES modules natively, but real applications usually still run their code through a bundler (Vite, esbuild, webpack, Rollup) before shipping it. A bundler does a few things at once:
- Combines dozens or hundreds of small module files into one (or a few) optimized files, cutting down on network requests
- Removes code that's never actually used (tree-shaking), which is only possible because ES module imports/exports are statically analyzable
- Transforms newer syntax into a form older browsers understand
- Handles non-JavaScript imports (CSS, images) that browsers alone can't resolve as modules
You won't build a bundler in this course, but it's worth knowing why it exists: modular source code and what actually ships to a user's browser are usually two different things, connected by a build step.
Module design
A few practical guidelines for splitting your own code into modules well:
- One clear responsibility per module. A
userAuth.jsmodule shouldn't also contain unrelated date-formatting helpers. - Export the smallest interface that's actually needed. If a helper function is only used internally, don't export it — a smaller public surface is easier to change later without breaking callers.
- Avoid circular dependencies (module A imports from B, which imports from A) — they're a sign the two modules are actually one concept split awkwardly in two, and they can cause subtle bugs about what's initialized when.
Key takeaways
- Modules give a file its own private scope plus an explicit, minimal public interface — the same idea as function-level encapsulation, applied at the file level.
- ES modules (
import/export) are the modern standard; CommonJS (require/module.exports) is the older Node.js system you'll still see often. - A bundler combines, optimizes, and transforms your modules into what actually ships to the browser.
- Keep each module focused on one responsibility, and export only what other modules genuinely need.
Try it yourself
- Split a single file containing
celsiusToFahrenheitandfahrenheitToCelsiusfunctions into atemperature.jsmodule with named exports, and anapp.jsthat imports and uses both. - Rewrite the same module using CommonJS syntax instead, and note every line that had to change.
- Explain, in your own words, why
importstatements always have to appear at the top level of a file (not inside anifblock) whilerequire()calls don't have that restriction.
Hints
export function celsiusToFahrenheit(c) { ... }, imported asimport { celsiusToFahrenheit, fahrenheitToCelsius } from './temperature.js';.- Replace every
exportwith an addition tomodule.exports = { ... }, and everyimport { x } fromwithconst { x } = require(...). - Tooling needs to determine a module's full dependency graph before running any code, to enable tree-shaking and bundling — that's only possible if imports are static and unconditional.
Summary
Modules are how JavaScript scales from "a script" to "an application" — the same discipline of small, well-named, minimal-interface pieces you learned for functions, now applied to entire files. Next, we tackle a genuinely different kind of complexity: code that doesn't run top-to-bottom in one go, because it has to wait on things that take time.