Skip to the chapter
←JavaScriptGGroundworkJSJavaScript
JavaScript/Intermediate/I5
22 / 41 written chapters
/

The cover
  1. B1Setup & mental model
  2. B2Execution context
  3. B3var, let & const
  4. B4Scope
  5. B5Single thread
  6. B6In the browser
  7. B7Types & values
  8. B8Strings & Math
  9. B9Operators & flow
  10. B10Functions
  11. B11Objects & arrays
  12. B12this
  13. B13Closures
  14. B14DOM & events
  15. B15Callbacks & promises
  16. B16Errors & tools
  17. B17Guided project
  18. ★Cheat page
  1. I1Scope & functions, properly
  2. I2Objects deeply
  3. I3Prototypes & OOP
  4. I4Async, properly
  5. I5Modules & tooling
  6. I6Regex & dates
  7. I7Error handling
  8. I8Real-time connections
  9. I9Offline & storage
  10. I10Browser APIs
  11. I11Modern JS
  12. ★Cheat page
  1. A1Engine & memory
  2. A2Advanced async
  3. A3Metaprogramming
  4. A4Types & data
  5. A5Patterns & architecture
  6. A6Performance
  7. A7Security
  8. A8Ecosystem
  9. A9Testing
  10. A10Observability
  11. A11Interview bank
  12. A12Build it yourself
  13. ★Cheat page

JSI5 · Part 2 · Intermediate

Modules & tooling

Everything that turns files full of JS into one thing a browser can run.

7 min read10 sections2 exercises
22 of 41

In this chapter

  • ESM — import and export
  • Circular imports — what actually happens
  • import.meta and import attributes
  • CommonJS vs ESM
  • Dynamic import()

and 5 more

about 7 min
10On this page
  1. ESM — import and export
  2. Circular imports — what actually happens
  3. import.meta and import attributes
  4. CommonJS vs ESM
  5. Dynamic import()
  6. Import maps — bare specifiers, without a bundler
  7. npm, package.json, semver
  8. What package.json's exports field controls
  9. A bundler, briefly
  10. Linting and formatting

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

StepWhat the engine doesConsole output
1app.js starts; hits import { PI } from "./math.js" before any of its own code runs — pauses(nothing yet)
2math.js starts; hits import { env } from "./config.js" before its own code — pauses(nothing yet)
3config.js has no imports, so it runs to completion immediately(no output — it has no console.log)
4math.js resumes, now that config.js has finished, and runs its own top-level code"math.js loaded, env = prod"
5app.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
Syntaxrequire() / module.exportsimport / export
Loadingsynchronouscan be async (dynamic import())
Resolvedat runtime, can be conditionalstatically, before execution
Top-level thismodule.exportsundefined
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.0anything up to, not including, 19.0.0 — new minors and patches, never a new major
~18.2.0anything up to, not including, 18.3.0 — patches only
18.2.0that 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's exports field 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 though package.json already lists versions.
  • Name a bundler's three jobs (graph, transpile, pack) and say what a source map is actually for.

02Practice this chapter

Opens in the editor: write it, run it, and check it against real tests.

  • 8 testsDoes this version satisfy a caret range?intermediate
  • 9 testsBuild a left-to-right pipeintermediate
All 33 for this level →
I5

Finished reading?

0 of 41 chapters read in this series

→Next stop

  1. I4Previous · I4Async, properly
  2. I5you are here
  3. Next · I6Regex & datesTwo toolboxes every real app ends up reaching for, and the traps that come with each.5 min read

01On this page

10 sections · 0%

  1. 01ESM — import and export
  2. 02Circular imports — what actually happens
  3. 03import.meta and import attributes
  4. 04CommonJS vs ESM
  5. 05Dynamic import()
  6. 06Import maps — bare specifiers, without a bundler
  7. 07npm, package.json, semver
  8. 08What package.json's exports field controls
  9. 09A bundler, briefly
  10. 10Linting and formatting

[] chapters · t top