Command Palette

Search for a command to run...

Hectal
PHASE 12Intermediate ES2015+ ~31 min· topic 1 of 6

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() and module.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.

prices.jswhole filejavascript
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.

app.jswhole filejavascript
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.

index.htmlwhole filehtml
<!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.

terminal
$ python -m http.server 8000
── expected output ──
Serving HTTP on :: port 8000 (http://[::]:8000/) ...

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.

How the browser loads a module graphdiagram
Rendering diagram…

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.

prices.cjswhole filejavascript
// 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. 1

    Add a named export

    In the first example, add export const CURRENCY = 'Rs'; inside the data URL (before export default). Then import CURRENCY next to TAX and put it in front of the price. Predict: does the order of names inside the braces matter?

  2. 2

    Spell an import wrong

    Change withTax to withTaxes in the import line only. Predict: does the first console.log line still run? Run it and read the error: it comes from the linking phase, so no line of that module runs.

  3. 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 times counter module runs appears and what the new copy prints.

Code & diagrams

Named and default imports (an import map stands in for a second file) New tab

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

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

Hectal Shop 0.18 118
Module scripts versus classic scripts New tab

Three 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

Press Run to see the page.

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 defined
Live bindings, and each module runs once New tab

The 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

Press Run to see the page.

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
Every export and import formjavascript
// ----- 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 1

Break 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.

terminal
$ Console
── what you'll see ──
Uncaught SyntaxError: Cannot use import statement outside a module

Break #2

Forget the ./ in a specifier

In a real project, write import { withTax } from "prices.js"; (no ./) and open the page.

terminal
$ Console
── what you'll see ──
Uncaught TypeError: Failed to resolve module specifier "prices.js". Relative references must start with either "/", "./", or "../".

Break #3

Import a name that isn't exported

In the first example, change the import to import shopName, { TAX, withTax, discount } from "prices";.

terminal
$ Console
── what you'll see ──
Uncaught SyntaxError: The requested module 'prices' does not provide an export named 'discount'

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 import statements appear. With a circular import (A imports B, B imports A), one of them runs first and sees the other's let/const exports still uninitialised: touching them throws ReferenceError: 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.url is the module's own URL, so new URL("./data.json", import.meta.url) finds a file next to the module wherever it is served from. Node.js also offers import.meta.dirname and import.meta.filename.

  • ▸

    A deep import graph means a waterfall: the browser only discovers prices.js after downloading and parsing app.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. 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 (the window object in a browser), so two files that both declared let total clashed, 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. 2

    **export marks what a module shares. A named export** has a name: export const TAX = 0.18; or export 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. 3

    **import** brings exports in. Named imports use braces and must match the exported name exactly: import { TAX, withTax } from "./prices.js";, renamed with as: 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"; then prices.TAX. A module can also pass exports along: export { withTax } from "./prices.js"; or export * from "./prices.js"; (a re-export, used for "barrel" files like index.js).

  4. 4

    The text after from is 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 with Failed 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 from node_modules for you (Topics 12.3 and 12.4).

  5. 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, like defer from Topic 0.2), runs in strict mode without writing "use strict" (assigning to an undeclared variable throws a ReferenceError), has this equal to undefined at its top level, and its top-level var and function declarations do not become properties of window. Module files are fetched with CORS (the browser's rules for loading from other sites), so they must be served over http:// or https://: opening index.html straight from disk (file://) blocks them.

  6. 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 = 5 throws TypeError: 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. 7

    import declarations are static: they must sit at the top level of a module (not inside an if or 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 a SyntaxError before 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, use import() (Topic 12.2).

Explain it without notes

01

What is the difference between a named export and a default export, and how do you import each?

02

List the ways a <script type="module"> behaves differently from a classic <script>.

03

Why are imports called live bindings, and what happens if an importer assigns to one?

04

How do ES modules differ from CommonJS in Node.js?

Practice

01

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.

02

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).

03

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 as and * 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.