JavaScript ES Modules
ES Modules (ESM) are the official, standardized way to split JavaScript code into separate files that explicitly declare what they export and what they import from other files. Introduced in ES2015 (ES6) and now supported natively by every modern browser and by Node.js, ES Modules replaced the tangle of competing module formats — CommonJS, AMD, UMD — that JavaScript developers juggled for years. Because virtually every modern build tool, framework, and runtime is built around this format, understanding how import and export actually work, not just how to type them, is essential for writing maintainable JavaScript.
Overview: How ES Modules Work
A module in JavaScript is simply a file. Anything declared inside a module — variables, functions, classes — is scoped to that file by default; nothing leaks into the global scope the way top-level variables in a plain script do. To share something from a module you must explicitly export it, and to use something from another module you must explicitly import it. This explicitness is one of the biggest advantages of ES Modules: just by reading the top of a file, you can see exactly what it depends on and exactly what it makes available to others.
Every ES module automatically runs in strict mode — this cannot be turned off. That means mistakes like assigning to an undeclared variable throw real errors instead of silently creating a global.
Under the hood, when the JavaScript engine encounters an ES module, it does not simply run the file top to bottom. It goes through three phases. First, construction: the engine finds every file referenced by an import statement, fetches it, and parses it into a module record, building a full dependency tree called the module graph. Second, instantiation: the engine allocates memory for every exported and imported binding and links them together — this linking happens before any module code runs, which is why imports behave as if they were hoisted to the top of the file. Third, evaluation: each module’s top-level code actually runs, exactly once, in dependency order, so a module’s dependencies always finish evaluating before the module that needs them. Because linking happens statically before evaluation, the engine can catch a typo in an imported name or a missing export before any code executes at all — something CommonJS’s require(), which resolves everything at runtime, cannot do.
Another core property of ES Modules is that they are singletons: no matter how many files import the same module, that module’s code executes only once, and every importer receives a reference to the very same live bindings, not a copy. If one module later changes an exported variable, every other module that imported it sees the new value immediately. This is fundamentally different from CommonJS, where module.exports produces a static snapshot at the moment require() runs.
Syntax
ES Modules offer several export and import forms, and real-world code typically mixes them:
| Form | Example | Meaning |
|---|---|---|
| Named export | export const x = 1; |
Exports one specific binding by name; a module can have many named exports. |
| Named import | import { x } from './file.js'; |
Imports one or more named exports; the name must match exactly (or be renamed). |
| Default export | export default function() {} |
Exports a single ‘main’ value for the module; at most one per module. |
| Default import | import myFunc from './file.js'; |
Imports the default export under any local name you choose. |
| Renaming | import { x as y } from './file.js'; |
Imports a named export under a different local name. |
| Namespace import | import * as utils from './file.js'; |
Imports all named exports as properties of a single object. |
| Re-export | export { x } from './file.js'; |
Forwards another module’s export without creating a local binding for it. |
| Dynamic import | const mod = await import('./file.js'); |
Loads a module asynchronously at runtime and returns a promise. |
In Node.js, a file is treated as an ES module either because it has a .mjs extension, or because the nearest package.json contains "type": "module". In a browser, a script is treated as a module when it is loaded with <script type="module">.
Examples
Example 1: Named Exports and Imports
The most common pattern is exporting several related values from a utility file and importing only the ones you need elsewhere.
// mathUtils.js
export const PI = 3.14159;
export function add(a, b) {
return a + b;
}
export function multiply(a, b) {
return a * b;
}
// main.js
import { PI, add, multiply } from './mathUtils.js';
console.log(add(2, 3));
console.log(multiply(4, PI));
console.log(PI);
Output:
5
12.56636
3.14159
Notice that main.js only imports the three names it actually needs, using the exact names declared with export in mathUtils.js. The braces { } signal a named import; the import list must match the export names (a renamed import with as can change the local name if there’s a clash).
Example 2: Default Export Combined With Named Exports
A module can export one default value alongside any number of named exports. This is a common pattern for a file whose ‘main’ purpose is a single function or class, plus a few supporting constants.
// logger.js
export default function log(message) {
console.log(`[LOG]: ${message}`);
}
export const LOG_LEVEL = 'info';
// app.js
import log, { LOG_LEVEL } from './logger.js';
log(`Starting app in ${LOG_LEVEL} mode`);
Output:
[LOG]: Starting app in info mode
The default import, log, is not wrapped in braces and can be given any local name — the importer chooses it, since a default export has no fixed name to match. The named import { LOG_LEVEL } still has to match the exported name exactly.
Example 3: Dynamic import()
Static import statements must appear at the top of a file and are resolved before any code runs. Sometimes you only want to load a module conditionally, or after some event — for that, use the dynamic import() function, which returns a promise and can be called from anywhere in your code, including inside functions and conditionals.
async function loadMathUtils() {
const { add, multiply } = await import('./mathUtils.js');
console.log(add(10, 5));
console.log(multiply(10, 5));
}
loadMathUtils();
Output:
15
50
Because import() is an expression, not a declaration, it can be used lazily — for example to load a large module only when a user clicks a button, reducing the amount of code that has to be downloaded and parsed up front. This technique is the basis of ‘code splitting’ in most modern bundlers.
Under the Hood: Live Bindings
Unlike CommonJS, where importing a value copies whatever it was at the moment of require(), ES Module imports are live bindings — a direct reference to the exporting module’s variable, not a snapshot of its value.
// counter.js
export let count = 0;
export function increment() {
count++;
}
// main.js
import { count, increment } from './counter.js';
console.log(count);
increment();
console.log(count);
Output:
0
1
Even though main.js never reassigns count itself, the second console.log shows the updated value because count in main.js is bound to the same underlying storage as count in counter.js, not a copy taken at import time. This is also why you cannot assign to an imported name directly (count = 5 from within main.js is not allowed) — you can only observe changes made by the module that owns the export.
This live-binding behavior, combined with the fact that every module is evaluated exactly once and cached by its resolved URL or path, is also what makes circular imports (module A imports from module B, which imports from module A) survive in ES Modules more gracefully than in CommonJS: since bindings are live references rather than copies, a value that isn’t ready yet when the cycle is first encountered can still be correct by the time it’s actually used, as long as it isn’t read during the immediate top-level evaluation of the cycle.
Common Mistakes
Mistake 1: Mixing require() into an ES module. Once a file is being treated as an ES module (via .mjs or "type": "module"), the CommonJS globals require, module, and __dirname are not available.
// This file is loaded as an ES module ("type": "module" in package.json)
import fs from 'fs';
const path = require('path');
console.log(path.join('a', 'b'));
Output:
ReferenceError: require is not defined in ES module scope
The fix is to stay consistent: import everything with import, including built-in Node modules like path: import path from 'path';. If you truly need a CommonJS-style module in the same project, use the module namespace import, import * as path from 'path', or give the file a .cjs extension instead.
Mistake 2: Omitting the file extension in a relative import path. Node’s CommonJS require('./mathUtils') happily resolves a missing extension for you, but native ES Modules do not perform that guessing in Node or in browsers — import { add } from './mathUtils' (without .js) throws a module-not-found error. Always write the full relative path, including the extension: import { add } from './mathUtils.js';. This is a frequent source of confusion for developers moving from CommonJS-based projects.
Best Practices
- Prefer named exports for utility modules with several related values, and reserve a default export for a file whose entire purpose is one function, class, or component.
- Always include the file extension in relative import paths (
./utils.js, not./utils) — native ESM resolution requires it. - Keep imports at the top of the file; even though they are hoisted, placing them elsewhere hurts readability and can be flagged by linters.
- Avoid deeply circular module dependencies where possible — they still work thanks to live bindings, but they make execution order hard to reason about.
- Use dynamic
import()for code splitting: heavy, rarely-used modules (large libraries, admin-only screens) should load on demand, not on every page load. - Don’t mutate an imported value’s contents unless the exporting module clearly documents it as mutable shared state; treat imports as read-only by convention even where the engine allows mutating an imported object’s properties.
- Set
"type": "module"inpackage.jsonfor new Node.js projects rather than relying on the.mjsextension everywhere, unless you need to mix module types in the same folder. - Use a namespace import (
import * as utils) sparingly — it pulls in everything from a module and can make it harder for bundlers to tree-shake unused exports.
Practice Exercises
- Create two files,
shapes.jsandmain.js. Inshapes.js, export named functionsareaOfCircle(radius)andareaOfSquare(side), plus a default export functiondescribeShape(name, area)that logs a formatted sentence. Inmain.js, import all three and use them together. - Write a module
settings.jsthat exports a mutableletvariable calledtheme(initially'light') and a functionsetTheme(newTheme)that reassigns it. In another file, import both, logtheme, callsetTheme('dark'), and logthemeagain. Explain in your own words why the second log reflects the change even though you never reassigned the imported binding yourself. - Using dynamic
import()inside anasyncfunction, write code that only loads and uses a (hypothetical)heavyMath.jsmodule when a boolean flagneedsHeavyMathistrue, and skips loading it entirely otherwise.
Summary
- ES Modules are file-scoped by default; you must explicitly
exportto share andimportto consume. - Every ES module runs in strict mode automatically, and its code is evaluated exactly once, no matter how many files import it.
- Named exports/imports use
{ }and must match exact names (optionally renamed withas); a module can have at most one default export. - Imports are static and resolved before any code runs, which lets engines catch missing or misspelled exports early — use dynamic
import()when you need runtime, conditional loading instead. - Imported bindings are live references to the exporting module’s variables, not copies, which is why a change in the source module is visible to every importer.
- In Node.js, enable ES Modules with
"type": "module"inpackage.jsonor the.mjsextension, and always include file extensions in relative import paths.
