JavaScript · Intermediate
Modules in JavaScript
In short: A JavaScript module is a file with its own scope that shares only what it marks with export; other files bring those names in with import. ES modules (ES2015) load in browsers through <script type="module"> and in Node.js through .mjs files or "type": "module", run in strict mode, and are evaluated once no matter how many files import them.
Why files need boundaries
A program of any size is split across files, and the question is what one file can see of another. In classic scripts the answer was everything: each <script> ran in the same global scope, so a total in one file silently collided with a total in another, and load order decided which functions existed when. ES modules fix this by giving every file its own scope. A top-level const in a module is private to that module unless the module explicitly exports it, and another module gets it only by explicitly importing it. The dependencies of a file are written at its top, where a reader and a tool can see them.
A module can have any number of named exports: export const TAX_RATE = 0.2 or export function withTax(amount) { ... }, or a list at the end, export { withTax, TAX_RATE }. Importers pick the names they want in braces, import { withTax } from "./pricing.js", optionally renaming with as. A module can also have one default export, export default class Invoice { ... }, which the importer names however it likes without braces: import Invoice from "./pricing.js". import * as pricing from "./pricing.js" collects every named export into one namespace object.
Three properties of modules matter in practice. They are strict mode automatically, so undeclared assignments and duplicate parameter names are errors. They are evaluated once: however many files import ./config.js, its top-level code runs a single time and every importer shares the same exported objects, which makes a module a natural place for shared state such as a settings object or a cache. And imports are live bindings: an importer sees the current value of an exported let, not a copy taken at import time.
In the browser, a module is loaded with <script type="module" src="app.js">. Module scripts are deferred by default, so they run after the document has been parsed, and import paths must be real URLs: "./pricing.js" with the extension, not the bare "pricing" that bundlers accept. Modules are fetched with CORS rules, which is why opening an HTML file from disk with file:// usually fails to load them; serve the folder over HTTP instead. import() as a function (ES2020) loads a module on demand and returns a promise, which is how large applications defer code the user may never need.
Node.js has two module systems. The older CommonJS uses require() and module.exports and is still the default for .js files unless the nearest package.json says "type": "module"; .mjs files are always ES modules and .cjs files always CommonJS. Mixing them is possible but has rules, so new projects usually pick ES modules throughout.
The programs on this site run as a single script, so import and export cannot appear in the runnable examples below. Instead they simulate the two ideas that make modules work, a private scope with a public interface and a registry that evaluates each module once, using ordinary functions. The syntax block shows the real thing.
Syntax (real module code, shown but not run here)
// pricing.js
export const TAX_RATE = 0.2;
export function withTax(amount) { return amount * (1 + TAX_RATE); }
export default class Invoice { /* ... */ }
// app.js
import Invoice, { withTax, TAX_RATE as rate } from "./pricing.js";
import * as pricing from "./pricing.js";
import("./reports.js").then((mod) => mod.build()); // dynamic import, ES2020
// index.html
<script type="module" src="./app.js"></script>
// CommonJS (Node.js, older style)
const { withTax } = require("./pricing.js");
module.exports = { withTax };Browser import paths need the file extension and a relative or absolute URL. Only one default export per module; any number of named exports.
A module in miniature: private scope, public interface
A function runs once and returns the object it wants to share; everything else inside it stays hidden. This is the module pattern, and it is exactly what an ES module does with export.
const inventory = (function () {
const items = new Map();
function add(name, qty) {
items.set(name, (items.get(name) || 0) + qty);
}
function report() {
return [...items].map(([name, qty]) => `${name}=${qty}`).join(", ");
}
return { add, report };
})();
inventory.add("bolts", 40);
inventory.add("nuts", 25);
inventory.add("bolts", 10);
console.log(inventory.report());
console.log(typeof items);
console.log(Object.keys(inventory));Output
bolts=50, nuts=25 undefined [ 'add', 'report' ]
The function is called immediately, so its body is the module's top level: items is a private Map and add and report are its functions. Only the returned object escapes, and typeof items outside is "undefined". In a real file the same code would drop the wrapper and write export function add and export function report, with items staying private because it is not exported.
Evaluated once: a tiny module registry
defineModule registers a factory; importModule runs it the first time it is asked for and caches the result. Three modules depend on each other; watch how many times each is evaluated.
const registry = new Map();
const cache = new Map();
function defineModule(name, factory) {
registry.set(name, factory);
}
function importModule(name) {
if (cache.has(name)) return cache.get(name);
console.log(`evaluating ${name}`);
const exports = registry.get(name)(importModule);
cache.set(name, exports);
return exports;
}
defineModule("units", () => ({
toGrams: (kg) => kg * 1000
}));
defineModule("recipes", (imp) => {
const { toGrams } = imp("units");
return {
flourFor: (loaves) => `${toGrams(0.5 * loaves)} g flour`
};
});
defineModule("shopping", (imp) => {
const { toGrams } = imp("units");
const recipes = imp("recipes");
return {
list: (loaves) => [recipes.flourFor(loaves), `${toGrams(0.01 * loaves)} g yeast`]
};
});
const shopping = importModule("shopping");
console.log(shopping.list(4));
console.log(importModule("units") === importModule("units"));Output
evaluating shopping evaluating units evaluating recipes [ '2000 g flour', '40 g yeast' ] true
Importing shopping pulls in units and then recipes; when recipes asks for units in turn, the cache already has it, so units is evaluated once even though two modules import it. That is the ES module guarantee, and it is why a module can safely hold shared state. The last line shows that every importer receives the same exports object, not a copy.
ES modules versus CommonJS
| Aspect | ES modules | CommonJS (Node.js) |
|---|---|---|
| Export | export / export default | module.exports = ..., exports.name = ... |
| Import | import { x } from "./m.js" (static, top level) | const { x } = require("./m") (a function call, anywhere) |
| Loading | asynchronous, resolved before evaluation | synchronous, at the require call |
| Bindings | live: importer sees later reassignments | the exports object as it is at require time; destructured names do not update |
| Strict mode | always | only with "use strict" |
| Where | browsers and Node.js | Node.js only |
| File hint in Node.js | .mjs or "type": "module" | .cjs or the default for .js |
Common mistakes
Loading a module with a plain script tag
Why it goes wrong:
<script src="app.js">runs the file as a classic script, and the firstimportline is a SyntaxError because import is only valid inside a module.Fix: Add
type="module"to the tag. Module scripts are deferred automatically, so a separatedeferattribute is unnecessary.JavaScript · fix<script type="module" src="./app.js"></script>Mixing up default and named import syntax
Why it goes wrong:
import { Invoice } from "./pricing.js"looks for a named export calledInvoiceand fails if the module only hasexport default. The reverse, importing a named export without braces, silently gives you the default instead.Fix: Braces for named exports, no braces for the default. Check the exporting file when a name comes back
undefined.Expecting a module's top-level variables to be global
Why it goes wrong: Code in another file, or in the browser console, cannot see a module's
constorfunctionunless it was exported and imported. Inline handlers such asonclick="save()"cannot reach a function defined in a module either.Fix: Export what other files need, and attach event listeners from inside the module with
addEventListenerinstead of inline attributes.Omitting the file extension in a browser import
Why it goes wrong: Bundlers resolve
"./pricing"topricing.js, but a browser requests exactly the URL written, and./pricingdoes not exist on the server.Fix: Write the full path with its extension,
"./pricing.js", or set up an import map for bare names.
Where you use this
Any front-end project beyond a single file is organised as modules: one for talking to the server, one for formatting dates and money, one per component or page. Each file exports a small interface and keeps its helpers private, so a change inside one module cannot break another by accident and tests can import one module on its own. Tooling builds on the same explicit dependency list: bundlers follow the imports to decide which code the page needs and drop exports nobody imports.
On the server, Node.js libraries are modules too, and the import at the top of a file documents what it depends on. Dynamic import() covers the case where a feature is rarely used or heavy, such as a chart library or an editor: the module is fetched only when the user opens that feature, keeping the initial load small.
// format.js
export function money(n) { return n.toFixed(2); }
// cart.js
import { money } from "./format.js";
export function lineTotal(qty, price) { return money(qty * price); }Key points
- Every module has its own scope; only exported names are visible to importers.
- Named exports are imported in braces and can be renamed with
as; a module has at most one default export, imported without braces. - Modules are strict mode and evaluated once; all importers share the same exports and see live bindings.
- In browsers use
<script type="module">, full paths with extensions, and serve over HTTP rather thanfile://. import()loads a module on demand and returns a promise (ES2020).- Node.js also has CommonJS with
requireandmodule.exports;.mjsor"type": "module"selects ES modules.
Try it yourself
The converter module exposes only toCm. Add a toInches function that divides by CM_PER_INCH, export it by adding it to the returned object, and uncomment the last line. With the input 10 the program should print 25.4 and then 5.
const converter = (function () {
const CM_PER_INCH = 2.54;
function toCm(inches) { return inches * CM_PER_INCH; }
return { toCm };
})();
const inches = Number(readline());
console.log(converter.toCm(inches));
// console.log(converter.toInches(12.7));1025.4
5const converter = (function () {
const CM_PER_INCH = 2.54;
function toCm(inches) { return inches * CM_PER_INCH; }
function toInches(cm) { return cm / CM_PER_INCH; }
return { toCm, toInches };
})();
const inches = Number(readline());
console.log(converter.toCm(inches));
console.log(converter.toInches(12.7));Practise this
Exercises for this lesson are in the JavaScript practice set.
Open the JavaScript playground
Related lessons
- Closures in JavaScriptWhat a JavaScript closure is, how a function keeps access to the scope it was created in, and how to use that for counters, factories and callbacks.10 min
- JavaScript Functions: Declarations, Arrows and Return ValuesHow to define and call JavaScript functions with declarations, expressions and arrow functions; parameters, default values, return and local scope.10 min
- Scope and Hoisting in JavaScriptHow JavaScript decides where a variable is visible: global, function and block scope, how var and function declarations hoist, and why let has a dead zone.10 min
Frequently asked questions
What is the difference between import and require in JavaScript?
import is the ES module syntax: it is static, hoisted to the top of the file, resolved before the module runs, and works in browsers and Node.js. require is the CommonJS function used by older Node.js code: it runs synchronously wherever it is called and returns a copy of module.exports. Node.js supports both, but a single file must use one system, chosen by its extension or the package's "type" field.
Can I use ES modules in a browser without a bundler?
Yes. All current browsers support <script type="module"> and import natively. You need to serve the files over HTTP (a local development server is enough), write import paths with their extensions, and either avoid bare package names or map them with an import map. Bundlers remain useful for combining many files and for npm packages, but they are not required.
What is a default export and when should I use one?
A default export is the single value a module presents as its main thing, imported without braces under any name the importer chooses. It suits modules that exist for one class or function, such as a component file. Named exports are better when a module offers several utilities, because the names are checked and tools can complete and refactor them.
How this page was checked. Every program on it was run with Node.js 22 at build time; runs in your browser in an isolated Web Worker by the publishing checks, and the output shown is what it printed. The Run buttons execute the same code on your device; nothing you type is sent anywhere.