I5 · Part 2 · Intermediate
Modules & tooling
Everything that turns files full of JS into one thing a browser can run.
10On this page
Nothing in this chapter runs in the sandbox the way earlier
runnable examples did — import/export
are only valid inside a real module, not inside an arbitrary function
body, so every example here is read, not clicked.
ESM — import and export
// math.js
export const PI = 3.14159;
export function square(n) { return n * n; }
export default function add(a, b) { return a + b; } // at most ONE default per module
// app.js
import add, { PI, square } from "./math.js"; // default + named, one import statement
import * as math from "./math.js"; // everything, under one namespace object
Rule An imported binding is a
live view into the exporting module, not a value copied once
at import time. If
math.js later reassigns an exported
let, every file that imported it sees the new value —
the same "reference, not snapshot" idea from
objects and arrays,
applied to module bindings instead of object properties.
This live-binding rule, plus imports being static — resolved
before any module code runs, always at the top level, never
conditional — is exactly what lets a bundler safely
tree-shake: it can see every import/export at compile time and
delete anything nothing else actually uses, something CommonJS's
fully dynamic require() can't guarantee.
// config.js — no imports of its own
export const env = "prod";
// math.js
import { env } from "./config.js";
export const PI = 3.14159;
console.log("math.js loaded, env =", env);
// app.js — the entry point
import { PI } from "./math.js";
console.log("app.js running, PI =", PI);
Dry run: evaluation order for app.js → math.js → config.js
| Step | What the engine does | Console output |
|---|---|---|
| 1 | app.js starts; hits import { PI } from "./math.js" before any of its own code runs — pauses | (nothing yet) |
| 2 | math.js starts; hits import { env } from "./config.js" before its own code — pauses | (nothing yet) |
| 3 | config.js has no imports, so it runs to completion immediately | (no output — it has no console.log) |
| 4 | math.js resumes, now that config.js has finished, and runs its own top-level code | "math.js loaded, env = prod" |
| 5 | app.js resumes, now that math.js has finished, and runs its own top-level code | "app.js running, PI = 3.14159" |
The order is always leaves-first: a module's own top-level code only runs
after every module it statically imports has finished evaluating — the
reverse of the order the import lines appear in. It's also why
a module imported by several others still only evaluates once: once
config.js has run, anything else that imports it later just
gets the already-evaluated module, not a second run.
Circular imports — what actually happens
// a.js
import { b } from "./b.js";
export const a = "a";
console.log("a.js sees b as:", b);
// b.js
import { a } from "./a.js";
export const b = "b";
console.log("b.js sees a as:", a);
Whichever module starts loading first sees the other's exports as
they existed at that moment — not necessarily finished yet.
If a.js is entered first, b.js gets pulled
in to satisfy its import, and by the time b.js reads
a, a.js hasn't reached its own
export const a line yet: a is
undefined there, not an error. This is exactly why the
live-binding rule above matters — the value updates once
a.js finishes running, so code that reads a
later (inside a function, not at the top level) sees the
real value. A circular import isn't automatically a bug, but reading
one's value at the top level, immediately, usually is.
import.meta and import attributes
console.log(import.meta.url); // this module's own URL — the ESM replacement for CommonJS's __filename
import data from "./config.json" with { type: "json" }; // import attributes — required for non-JS imports
import.meta is only valid inside a module, and only ever
describes the module it appears in — there's no equivalent of
Node's old global __dirname/__filename,
because ESM has no implicit scope shared across files. The
with { type: "json" } clause exists because the browser
otherwise has no way to know a .json file should be
parsed as data rather than executed as a script — a security
requirement, not decoration, and mandatory for JSON imports.
CommonJS vs ESM
| CommonJS (Node's original) | ESM | |
|---|---|---|
| Syntax | require() / module.exports | import / export |
| Loading | synchronous | can be async (dynamic import()) |
| Resolved | at runtime, can be conditional | statically, before execution |
Top-level this | module.exports | undefined |
| File markers | .cjs, or default in a plain package.json | .mjs, or "type": "module" in package.json |
Node runs both today; browsers only ever understood ESM
(<script type="module">). ESM is the forward
direction — new libraries default to it, and most tooling exists
partly to smooth over the gap for code still shipping CommonJS.
Dynamic import()
button.addEventListener("click", async () => {
const { openModal } = await import("./modal.js"); // only fetched when actually needed
openModal();
});
Unlike a static import, this one is a real function call
— it can go inside an if, a click handler, anywhere —
and it returns a promise. This is the mechanism behind
code splitting: a bundler sees a dynamic import()
and automatically cuts that module (and everything only it needs)
into its own separate file, downloaded only when that line actually
runs, instead of bloating the very first page load with code most
visitors may never trigger.
Import maps — bare specifiers, without a bundler
<script type="importmap">
{
"imports": {
"lodash": "/vendor/lodash.js",
"chart/": "/vendor/chart/"
}
}
</script>
<script type="module">
import debounce from "lodash"; // resolves to /vendor/lodash.js
import { Line } from "chart/line.js"; // resolves to /vendor/chart/line.js
</script>
Plain ESM in a browser only understands relative or full URLs —
import x from "lodash" with no map fails outright,
because there's no node_modules resolution algorithm
running in the browser itself. An import map is the browser-native
answer: a small table the page declares up front, letting bare
specifiers work without a bundling step — useful for a quick
prototype or a CDN-based setup that deliberately skips build tooling.
npm, package.json, semver
{
"name": "my-app",
"version": "1.4.2",
"dependencies": { "react": "^18.2.0" },
"devDependencies": { "vitest": "^4.1.10" }
}
A version is MAJOR.MINOR.PATCH — major for
breaking changes, minor for new, backward-compatible features,
patch for backward-compatible fixes. The prefix in front of a
dependency's version controls how far an install is allowed to drift:
| Range | Allows |
|---|---|
^18.2.0 | anything up to, not including, 19.0.0 — new minors and patches, never a new major |
~18.2.0 | anything up to, not including, 18.3.0 — patches only |
18.2.0 | that exact version, nothing else |
⚠ package.json alone isn't reproducible
^18.2.0 is a range, not one specific version — two
installs weeks apart can legitimately resolve to different actual
versions. package-lock.json (or yarn.lock,
pnpm-lock.yaml) pins the exact resolved tree,
which is why it's committed to the repo and why "works on my
machine" so often traces back to a missing or ignored lockfile.
What package.json's exports field controls
{
"name": "my-lib",
"type": "module",
"exports": {
".": "./index.js",
"./utils": "./src/utils.js"
}
}
Without exports, anything inside a published package is
reachable by path — import "my-lib/src/internal/helper.js"
works whether or not the author intended it to. The
exports field is an explicit allow-list: only the listed
paths are importable from outside, everything else throws
"not exported," even though the file is still physically there. It's
the module-system equivalent of a private field — a real, enforced
boundary between a package's public API and its implementation
details.
A bundler, briefly
A bundler (Vite, webpack, esbuild, Rollup) does three jobs at once:
follows every import to build one dependency graph,
transpiles newer syntax and JSX/TS down to something the
target browsers understand, and packs the result into as few files
as make sense (splitting where a dynamic import() says
to). The source map it emits alongside the bundle is what lets
a browser's DevTools show your original Button.tsx and
its real line numbers in a stack trace, instead of line 1 of one
giant minified file.
Linting and formatting
Two different jobs, often confused because they're configured together. ESLint reads your code for actual problems — an unused variable, a missing dependency in a React hook, a variable that shadows an outer one by accident. Prettier doesn't look for problems at all — it just rewrites every file into one consistent style (quotes, spacing, line length), so a diff shows what actually changed instead of a formatting argument. Running both: Prettier decides how the code looks, ESLint decides whether the code is right.
Interview answer, one sentence
"ES modules are static and evaluated once with live bindings, so imports can be analysed and tree-shaken and circular imports work but may see uninitialised values; CommonJS is dynamic and hands you whatever was exported at the moment you read it, which is where most interop bugs come from."
Before you move on
- Explain why an ES module's import binding is "live" rather than a copied value, and how that differs from CommonJS's
require(). - State the evaluation order for a small import graph — which module's top-level code runs first, and why — before running any code.
- Explain what problem
package.json'sexportsfield solves, and how it differs from every file being reachable by path. - Explain the difference between
^and~in a semver range, and why a lockfile is committed even thoughpackage.jsonalready lists versions. - Name a bundler's three jobs (graph, transpile, pack) and say what a source map is actually for.
Opens in the editor: write it, run it, and check it against real tests.
All 33 for this level →Finished reading?
0 of 41 chapters read in this series