Topic 12.1
ES Modules
In one line
An ES module is a JavaScript file with its own private scope that shares values with export and receives them with import; the browser loads modules with <script type="module">, runs them in strict mode, deferred, and only once each.
Think of it like this
A school with separate classrooms. Each classroom (a module) has its own whiteboard: what is written there stays there. If a class wants to share something, it pins it on the notice board by the door (export). Another class walks over and copies only the notices it needs (import). Nobody can scribble on another class's whiteboard, and two classes can both use the word "total" without a fight.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Module
- One JavaScript file with its own private scope, which shares values only through
export. - Scope
- The area of code where a name can be used. A module's top-level names are visible only inside that file.
- Named export
- A value shared under its own name, imported with braces:
import { TAX } from "./prices.js". - Default export
- The one main value a module offers, imported without braces under any name you choose.
- Module specifier
- The text after
from, such as"./prices.js", that says which module to load. - Bare specifier
- A specifier that is only a name, like
"lodash", with no./or URL. Browsers need an import map to understand it. - Import map
- A
<script type="importmap">block of JSON that tells the browser which URL a module name stands for. - Live binding
- An import that always shows the exporting module's current value, rather than a copy taken once.
- Strict mode
- A stricter set of JavaScript rules that turns some silent mistakes into errors. Modules always use it.
- CommonJS
- Node.js's older module system, with
require()andmodule.exports, still found in many npm packages.
Step by step
01Write a module that exports
Create prices.js. Mark the shared pieces with export. round2 has no export, so it stays private to this file: other files can't see it at all.
export const TAX = 0.18;
function round2(n) {
return Math.round(n * 100) / 100;
}
export function withTax(price) {
return round2(price * (1 + TAX));
}
export default function formatPrice(n) {
return "Rs " + n.toFixed(2);
}02Import it from another module
In app.js, the default import (formatPrice, any name you like) comes first, then the named imports in braces, spelled exactly as exported. The ./ and the .js are required in the browser.
import formatPrice, { TAX, withTax } from "./prices.js";
const total = withTax(250);
document.querySelector("#total").textContent = formatPrice(total);
console.log("Tax rate:", TAX, "Total:", total);03Load the entry module from the page
Only the entry module (the first one) needs a <script> tag. The browser reads its imports, downloads prices.js, and so on down the tree. type="module" already defers, so the head is a fine place for it.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Shop</title>
<script type="module" src="app.js"></script>
</head>
<body>
<p>Total: <span id="total"></span></p>
</body>
</html>04Serve it over HTTP
Double-clicking index.html opens it as file://..., and the browser blocks module loading from there (the console shows a CORS error). Run any small local web server in the folder instead, then open the address it prints. VS Code's Live Server extension or Vite (Topic 12.4) do the same job.
05How the browser loads a module graph
Loading happens in three phases. Construction: fetch the entry file, parse it, find its imports, fetch those, and repeat until the whole graph is known. Linking: connect every import to the matching export (a typo in a name fails here, before any code runs). Evaluation: run each module's top-level code once, deepest dependencies first, so prices.js has finished before app.js starts.
06Meet the older system: CommonJS
Node.js had modules years before ES2015, with require() and module.exports. That system is CommonJS, and many npm packages still use it. It loads files synchronously at run time, while ES modules are static and can load asynchronously.
In Node.js, a file is treated as an ES module if it ends in .mjs, or ends in .js and the nearest package.json says "type": "module". Otherwise .js means CommonJS. New code should use ES modules everywhere.
// CommonJS (Node.js's original modules)
const TAX = 0.18;
function withTax(price) {
return price * (1 + TAX);
}
module.exports = { TAX, withTax };
// in another file:
// const { withTax } = require("./prices.cjs");Try it yourself
- 1
Add a named export
In the first example, add
export const CURRENCY = 'Rs';inside the data URL (beforeexport default). Then importCURRENCYnext toTAXand put it in front of the price. Predict: does the order of names inside the braces matter? - 2
Spell an import wrong
Change
withTaxtowithTaxesin theimportline only. Predict: does the firstconsole.logline still run? Run it and read the error: it comes from the linking phase, so no line of that module runs. - 3
Prove the module runs once
In the third example, copy the second
<script type="module">block and paste it again at the end. Predict how many timescounter module runsappears and what the new copy prints.
Code & diagrams
The preview is a single page, so the import map gives the name `prices` to a module written inline as a `data:` URL. In a real project the same line reads `import shopName, { TAX, withTax } from "./prices.js";`.
Preview
Console
Nothing logged yet.Expected console output
Hectal Shop 0.18 118Three differences in one run: the module waited until the page was parsed, its `var` stayed private (not on `window`), and strict mode turned the undeclared assignment into an error.
Preview
Console
Nothing logged yet.Expected console output
1. classic script
classic: this is window? true
classic: window.classicVar is hello
classic: a typo silently made a global, oops2 = 1
2. module script (deferred, though it comes first)
module: this is undefined
module: window.moduleVar is undefined
module: ReferenceError: oops is not definedThe counter module ran once and both scripts share it. `count` updated inside A without re-importing (a live binding), but A could not assign to it: only the module that owns `count` may change it.
Preview
Console
Nothing logged yet.Expected console output
counter module runs
A sees 0
A sees 2 after two increments
TypeError: Assignment to constant variable.
B sees 2// ----- shapes.js -----
export const PI = 3.14159; // named export (declaration)
export function area(r) { return PI * r * r; } // named export (function)
const SIDES = { triangle: 3, square: 4 };
export { SIDES as sides }; // named export, renamed
export default class Circle { // default export (one per module)
constructor(r) { this.r = r; }
}
// ----- index.js (a "barrel" that re-exports) -----
export { area, sides } from "./shapes.js";
export { default as Circle } from "./shapes.js";
export * from "./colours.js"; // every named export of colours.js
// ----- app.js -----
import Circle, { PI, area } from "./shapes.js"; // default + named
import { sides as SIDE_COUNTS } from "./shapes.js"; // rename on import
import * as shapes from "./shapes.js"; // namespace object
import "./analytics.js"; // run a module for its side effects only
console.log(area(2), SIDE_COUNTS.square, shapes.PI, new Circle(1).r);
// 12.56636 4 3.14159 1Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Use import in a classic script
In the first example, delete type="module" from the second script tag and press Run.
Break #2
Forget the ./ in a specifier
In a real project, write import { withTax } from "prices.js"; (no ./) and open the page.
Break #3
Import a name that isn't exported
In the first example, change the import to import shopName, { TAX, withTax, discount } from "prices";.
Myth vs fact
Myth
import makes a copy of the exported value.
Fact
It creates a live, read-only link to the exporter's variable. When the exporter changes it, importers see the change immediately.
Myth
If three files import the same module, it runs three times.
Fact
Each module URL is evaluated once per page (or per Node.js process). Every importer gets the same instance and the same state.
Myth
Modules need a bundler to work in the browser.
Fact
Every current browser runs ES modules natively. Bundlers are still useful for npm packages, fewer requests and older browsers, but they are not required.
Myth
You need "use strict" at the top of every module.
Fact
Module code is always strict. The directive is harmless but does nothing in a module.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Evaluation order is a depth-first, post-order walk of the import graph: a module runs after all its dependencies, in the order its
importstatements appear. With a circular import (A imports B, B imports A), one of them runs first and sees the other'slet/constexports still uninitialised: touching them throwsReferenceError: Cannot access 'x' before initialization. Function declarations are hoisted, so circular imports of functions usually work; breaking the cycle is cleaner. - ▸
Top-level
await(ES2022) is allowed only in modules. A module that awaits pauses its own evaluation and that of every module that imports it, while unrelated branches of the graph keep going. Use it for setup (loading config), not for slow work on the critical path. - ▸
import.meta(ES2020) holds data about the current module.import.meta.urlis the module's own URL, sonew URL("./data.json", import.meta.url)finds a file next to the module wherever it is served from. Node.js also offersimport.meta.dirnameandimport.meta.filename. - ▸
A deep import graph means a waterfall: the browser only discovers
prices.jsafter downloading and parsingapp.js.<link rel="modulepreload" href="prices.js">starts that download early. Bundlers flatten the graph into a few files for the same reason.<script nomodule>runs only in browsers that don't support modules, an old trick for serving legacy bundles.
Remember this
- 1
A module is one JavaScript file treated as a unit with its own scope: the variables and functions declared at its top level are private to that file. Before modules, every
<script>shared one global scope (thewindowobject in a browser), so two files that both declaredlet totalclashed, and the order of<script>tags had to be right by hand. ES modules (ES2015, short for ECMAScript modules, also called ESM) are the language's built-in answer. - 2
**
exportmarks what a module shares. A named export** has a name:export const TAX = 0.18;orexport function withTax(price) { ... }, or a list at the end:export { TAX, withTax };. You can rename on the way out:export { withTax as addTax };. A module can have any number of named exports. A default export is the one main thing a module offers:export default function formatPrice() { ... }. A module has at most one default. - 3
**
import** brings exports in. Named imports use braces and must match the exported name exactly:import { TAX, withTax } from "./prices.js";, renamed withas:import { withTax as addTax } from "./prices.js";. A default import has no braces and you choose the name:import formatPrice from "./format.js";. Both at once:import formatPrice, { TAX } from "./prices.js";. Everything as one object, called a namespace object:import * as prices from "./prices.js";thenprices.TAX. A module can also pass exports along:export { withTax } from "./prices.js";orexport * from "./prices.js";(a re-export, used for "barrel" files likeindex.js). - 4
The text after
fromis the module specifier. In browsers it must be a full URL or start with/,./or../, and must include the file extension:./prices.js, not./prices. A bare specifier such as"lodash"fails in a plain browser withFailed to resolve module specifier "lodash", unless an import map (<script type="importmap">) says which URL that name means. Bundlers and Node.js resolve bare names fromnode_modulesfor you (Topics 12.3 and 12.4). - 5
To load a module in a page, write
<script type="module" src="app.js"></script>. A module script is deferred automatically (it downloads in parallel and runs after the HTML is parsed, likedeferfrom Topic 0.2), runs in strict mode without writing"use strict"(assigning to an undeclared variable throws aReferenceError), hasthisequal toundefinedat its top level, and its top-levelvarand function declarations do not become properties ofwindow. Module files are fetched with CORS (the browser's rules for loading from other sites), so they must be served overhttp://orhttps://: openingindex.htmlstraight from disk (file://) blocks them. - 6
Imports are live bindings, not copies: if the exporting module later changes
count, every importer sees the new value. But importers can't assign to an import (count = 5throwsTypeError: Assignment to constant variable.); only the owning module can change it. And each module runs once per page, however many files import it: the browser keeps a module map from URL to module, so a second import reuses the same instance. That makes a module a natural place for shared state, and also means its top-level code is not a "run me every time" function. - 7
importdeclarations are static: they must sit at the top level of a module (not inside anifor a function), and the specifier must be a plain string. That lets the engine find every dependency before running any code: it loads the whole graph, links imports to exports (a missing export is aSyntaxErrorbefore anything runs), then evaluates each module once, dependencies first. Static structure is also what lets bundlers remove unused exports (tree shaking, Topic 12.4). When you need to choose at run time, useimport()(Topic 12.2).
Explain it without notes
What is the difference between a named export and a default export, and how do you import each?
List the ways a <script type="module"> behaves differently from a classic <script>.
Why are imports called live bindings, and what happens if an importer assigns to one?
How do ES modules differ from CommonJS in Node.js?
Practice
Write a module greet.js with a default export function greet(name) that returns Hello, <name>!, and a named export LANG = "en". Then write the import line that uses both and logs greet("Asha") and LANG.
Using an import map and a data: URL, make a live page whose module math exports square(n), and a module script that logs square(9).
A teammate wrote import { formatPrice } from "./format.js"; but format.js has export default function formatPrice() {...}. What error do they see, and what are two ways to fix it?
Trade-offs
- ↔
Many small modules keep code organised and allow tree shaking, but unbundled they cost one request each and a discovery waterfall; production sites usually bundle them (Topic 12.4).
- ↔
Default exports are short to import, but each file can name them differently and renames don't follow through tools; named exports are more consistent and easier to search.
- ↔
Module-level state (a shared counter or cache) is convenient because modules run once, but it is global state in disguise: harder to test and reset than state passed in explicitly.
Done when you can
I can write named and default exports and import each correctly, including
asand* as.I can load an entry module with
<script type="module">and serve it over HTTP.I know modules are deferred, strict, privately scoped and evaluated once.
I can explain live bindings and why an importer can't assign to an import.
I can read the common module errors: import outside a module, unresolved specifier, missing export.
I know how Node.js decides between ES modules and CommonJS.