JavaScript Modules
ES modules (ESM) ES2015 are the standard way to split JavaScript into files, in browsers and in Node. This chapter covers every form of import and export, live bindings, dynamic import(), import.meta, JSON modules, and how ESM coexists with Node's older CommonJS system.
You will learn
- Named and default exports, renaming, and re-exporting ("barrel" files).
- Import forms: named, default, namespace, side-effect; plus live bindings and module singletons.
- Dynamic
import()ES2020,import.meta, and top-levelawaitES2022. - Importing JSON with import attributes ES2025.
- ESM vs CommonJS in Node:
"type", file extensions,require, interop.
Why modules?
Before ES6, every <script> shared one global scope. Libraries hid their internals inside IIFEs (Chapter 4), and Node invented its own system, CommonJS (require/module.exports). ES modules standardise this for every environment:
- Each file has its own scope; only what you
exportis visible to others. - Dependencies are declared statically at the top, so tools can analyse them (tree-shaking removes unused code).
- Modules are strict and evaluated once; every importer shares the same instance.
Exporting
Importing
| Form | Example |
|---|---|
| Named | import { a, b } from "./m.js" |
| Renamed | import { a as alpha } from "./m.js" |
| Default | import anyName from "./m.js" |
| Namespace | import * as m from "./m.js" |
| Side effects only | import "./polyfills.js" |
| Dynamic | const m = await import("./m.js") |
⚠️ Pitfalls: in Node's ESM and in browsers, the file extension is required: write "./money.js", not "./money". A specifier without ./ or / (like "lodash") means a package. And import { x } is not object destructuring, so import { a: b } is a syntax error; use as.
💡 Named or default? Many teams prefer named exports only: the names are consistent across files, editors auto-import them, and renaming them is explicit. Default exports suit modules with one obvious main thing, like a React component.
Live bindings and singletons
Imports are live, read-only views of the exporting module's variables, not copies. And a module's code runs only once, however many files import it.
☕ Java: an ES module is roughly a class with only static members: loaded once, shared by everyone, and exposing a public API, with everything else private. But imports are file paths, not package-qualified class names, and there's no classpath.
Re-exporting and barrel files
A module can pass on what other modules export. A barrel (often index.js) gathers a folder's public API in one place.
Dynamic import() ES2020
import(specifier) is an expression that loads a module at run time and returns a promise for its namespace object. Use it for code that's rarely needed (lazy loading and code splitting), or for specifiers computed at run time. It also works in classic scripts and CommonJS.
import.meta and top-level await
import.meta holds information about the current module. In Node, import.meta.url is a file:// URL, and import.meta.dirname and import.meta.filename replace CommonJS's __dirname and __filename. import.meta.main (Node 24+) tells you whether this file was run directly, like Python's __name__ == "__main__".
Top-level await ES2022 pauses this module's evaluation, and every module that imports it waits too. Use it for set-up such as loading config, but avoid long waits in widely imported modules.
JSON modules ES2025
Import attributes tell the host what kind of module to expect. Importing JSON requires with { type: "json" }, so that a server can't trick your code into executing a "JSON" file as JavaScript.
📝 Older syntax you'll still see: import data from "./x.json" assert { type: "json" }, the earlier "import assertions" spelling, which has been removed from Node. Use with.
ESM and CommonJS in Node
| ES modules | CommonJS | |
|---|---|---|
| Syntax | import / export | require() / module.exports |
| Files | .mjs, or .js with "type": "module" | .cjs, or .js without it |
| Loading | static, async-capable, top-level await | synchronous, at run time |
| Extensions in specifiers | required | optional |
| File location | import.meta.dirname / filename | __dirname / __filename |
| Strict mode | always | only with "use strict" |
The other direction also works in modern Node (22+): a CommonJS file can require() an ES module, as long as that module doesn't use top-level await. In package.json, the "exports" field declares a package's public entry points for both systems.
In the browser
Module scripts are deferred automatically, fetched with CORS, and run once even if they're included twice.
More examples
1. Feature-flagged lazy loading
2. A module-level cache shared by all importers
3. "Run if main"
4. Inspecting a namespace object
The namespace object has a null prototype and can't be modified: its properties are live, read-only bindings. (It isn't technically frozen, because the values can still change inside the exporting module.)
5. Circular imports, and why functions survive them
Recap
exportnamed or default values;importnamed, default,* as nsor side-effects only. Rename withas, and always include file extensions.- Imports are live, read-only bindings. Each module is evaluated once and shared.
export * fromandexport { x } frombuild barrel files;export * as ns fromre-exports a namespace.import()loads lazily and returns a promise.import.meta.dirname,filenameandmainhelp in Node. Top-levelawaitdelays importers.- JSON needs
with { type: "json" }. In Node,"type": "module"or.mjsmeans ESM, and ESM can import CommonJS.
Exercises
Exercise 1 🟢 easy fix the bug
Node says Cannot find module '/…/utils' imported from /…/main.js. What's wrong with import { slug } from "./utils";?
Show solution
ESM resolution doesn't add extensions. Write import { slug } from "./utils.js";. CommonJS's require("./utils") guessed the extension; ES modules never do.
Exercise 2 🟢 easy predict the output
A module has export default 42; export const x = 1;. What do import y from "./m.js" and import * as ns from "./m.js" give for y and Object.keys(ns)?
Show solution
y is 42, and Object.keys(ns) is [ 'default', 'x' ]. The default export is simply the export named default.
Exercise 3 🟢 easy build it · playground
Split playground/money.js (with formatCents and addTax) from playground/main.js, which imports both and prints a formatted price with tax.
Show solution
The playground's package.json has "type": "module", so .js files are ES modules.
Exercise 4 🟢 easy explain why
Why does assigning to an imported binding (count = 5) throw, even though the exporting module declared it with let?
Show solution
Importers get a read-only view of the exporter's variable. Only the exporting module can change it (for example through an exported function like increment()), and importers then see the new value.
Exercise 5 🟡 medium refactor it
Convert this CommonJS module and its usage to ES modules.
Show solution
require becomes import, module.exports becomes export, built-ins get the node: prefix, and relative specifiers include .js.
Exercise 6 🟡 medium build it
Write a function loadPlugin(name) that dynamically imports ./plugins/${name}.js and calls its default export, returning null if the plugin doesn't exist.
Show solution
Checking e.code separates "plugin not found" from errors thrown by the plugin itself.
Exercise 7 🟡 medium build it · playground
Create a barrel playground/lib/index.js that re-exports everything from lib/money.js and lib/ids.js, but only search from lib/catalog.js. Import from it in one line.
Show solution
Barrels give a folder one public entry point. Keep them shallow, because large barrels can slow down tooling and hide circular dependencies.
Exercise 8 🟡 medium predict the output
log.js contains console.log("log loaded"); export const x = 1;. main.js imports it twice (once as import "./log.js" and once as import { x } from "./log.js") and then prints x. What's the output?
Show solution
log loaded (once), then 1. A module is evaluated a single time; all imports share that instance.
Exercise 9 🔴 hard explain why
Module a.js imports b.js, and b.js imports a.js and, at its top level, reads a const exported by a.js. Running node a.js throws ReferenceError: Cannot access 'X' before initialization. Why? Why did "More examples" #5 work?
Show solution
Node evaluates b.js first (it's a dependency of a.js), while a.js hasn't run yet, so a's const is still in its TDZ. In example #5, b only called a() later, inside a function, and a was a hoisted function declaration, already initialised. Avoid top-level use of circular imports.
Exercise 10 🔴 hard mini-project · playground
Create a mini plugin system in playground/plugins/: each plugin module exports name, appliesTo(order) and a default apply(order) that returns a new order (for example, a discount or free shipping). A run.js script reads the folder with fs.readdir, dynamically imports every .js file, and applies the matching plugins in alphabetical order.
Show solution
pathToFileURL turns an absolute path into a valid import specifier (important on Windows). Promise.all loads every plugin in parallel, and the immutable order updates keep each plugin independent. (In your playground, name the folder plugins/; here it's p/ to stay separate from Exercise 6.)