ES Modules
Understand how to split JavaScript code across multiple files using ES Modules — named exports, default exports, namespace imports, re-exports, module scope, and dynamic import.
As applications grow, keeping all code in one giant file becomes unmanageable. ES Modules (ECMAScript Modules, often abbreviated ESM) are JavaScript's native, standardized system for splitting code into separate files that can share functionality with each other through explicit import and export statements.
export and import basics
A module is simply a JavaScript file that uses export to make some of its values (functions, variables, classes) available to other files, and import to bring in values exported by other files.
There are two kinds of exports: named exports, where you export multiple, individually named values from a file, and a single default export per file, meant to represent 'the main thing' that file provides.
In the browser, modules are loaded with <script type="module" src="main.js"></script>. In Node.js, modules are used automatically in .mjs files, or in .js files when the nearest package.json has "type": "module".
Named exports
Named exports let a single file export several distinctly named values. You can export a declaration directly with the export keyword (export function add() {...}), or declare things normally and export them together at the bottom of the file (export { add, subtract };).
When importing named exports, the names inside the curly braces must match exactly what was exported: import { add, subtract } from './math.js';. You can rename an import to avoid a naming collision using the as keyword: import { add as addNumbers } from './math.js';.
Default exports
A file can have at most one default export, declared with export default. It represents the single primary thing the module provides — often a class, a main function, or a component.
Importing a default export does not use curly braces, and you can name it whatever you like on import, since there's no fixed name to match: import MyClass from './MyClass.js'; (the name 'MyClass' here is chosen by the importer, not enforced by the exporter).
Namespace imports and re-exports
A namespace import brings in all of a module's named exports bundled into a single object, using import * as name from './module.js';. You then access each export as a property of that object, like utils.add(2, 3).
Re-exporting lets one module act as a single entry point that gathers and forwards exports from several other files, which is common for organizing a library's public API: export { add, subtract } from './math.js'; re-exports those names without needing a separate import statement first.
Module scope and dynamic import
Every module has its own top-level scope — variables and functions declared in one module (even without export) are private to that file and never leak into the global scope or into other modules, unlike old-style plain <script> tags which all shared one global scope.
Static import statements (import ... from ...) must appear at the top level of a file and are resolved before any code runs. Dynamic import() is different: it's a function-like expression that can be called anywhere, including inside an if statement or event handler, and it returns a Promise that resolves to the module's exports — useful for loading code only when it's actually needed (code-splitting).
Named export, default export, and dynamic import
A default import (greet) and named imports ({ add, PI }) can be combined in one import statement. Module file paths for relative imports must include the file extension in the browser (e.g. './utils.js', not './utils'). import() as a function call is always asynchronous and returns a Promise, unlike the static import statement.
// --- utils.js ---
export function add(a, b) {
return a + b;
}
export const PI = 3.14159;
export default function greet(name) {
return `Hello, ${name}!`;
}
// --- main.js ---
import greet, { add, PI } from './utils.js';
console.log(greet('Ada'));
console.log(add(2, PI));
// Dynamic import, loaded only when needed
button.addEventListener('click', async () => {
const module = await import('./heavyFeature.js');
module.runFeature();
});Code Examples
mathUtils.js exports two named functions individually, each with export in front of its declaration. main.js imports exactly those two names inside curly braces, matching the exported names exactly, and can then call them directly as if they were declared locally. Expected result: 5 6 import { add, multiply } from './mathUtils.js';
api.js has both a default export (the ApiClient class) and several named exports (BASE_URL, getUser). app.js imports the default export under the name ApiClient, and simultaneously imports every named export bundled together into a single namespace object called api, accessing each one as api.BASE_URL or api.getUser(). Expected result: https://api.example.com import ApiClient, * as api from './api.js';
shapes/index.js acts as a single, organized public entry point: it re-exports each shape module's area function under a distinct renamed export (circleArea, squareArea) so callers only need one import path instead of remembering every individual file. showChart() demonstrates dynamic import() — the charting module is only fetched and evaluated when showChart() actually runs, which keeps the initial page load lighter. Expected result: 12.566370614359172 9 Renaming with 'as' during re-export is essential here since both circle.js and square.js independently export a function named area — without renaming, the two names would collide at the re-export point.
export { area as circleArea } from './circle.js';
// FILE: mathUtils.js
export function add(a, b) {
return a + b;
}
export function multiply(a, b) {
return a * b;
}
// FILE: main.js
import { add, multiply } from './mathUtils.js';
console.log(add(2, 3)); // 5
console.log(multiply(2, 3)); // 6// FILE: api.js
export const BASE_URL = 'https://api.example.com';
export function getUser(id) {
return fetch(`${BASE_URL}/users/${id}`).then(r => r.json());
}
export default class ApiClient {
constructor(token) {
this.token = token;
}
}
// FILE: app.js
import ApiClient, * as api from './api.js';
const client = new ApiClient('secret-token');
console.log(api.BASE_URL); // 'https://api.example.com'
api.getUser(1).then(user => console.log(user));// FILE: shapes/circle.js
export function area(radius) {
return Math.PI * radius * radius;
}
// FILE: shapes/square.js
export function area(side) {
return side * side;
}
// FILE: shapes/index.js (a single entry point re-exporting both)
export { area as circleArea } from './circle.js';
export { area as squareArea } from './square.js';
// FILE: app.js
import { circleArea, squareArea } from './shapes/index.js';
console.log(circleArea(2)); // 12.566...
console.log(squareArea(3)); // 9
// Later, load an expensive charting module only when actually needed
async function showChart() {
const { renderChart } = await import('./charting.js');
renderChart();
}One module importing from several others
main.js pulls in exported values from utils.js, api.js, and ui.js. Each of those files has its own private module scope and only shares what it explicitly exports.
main.js
|---- import ----> utils.js
|
|---- import ----> api.js
|
|---- import ----> ui.jsImportant Rules
A file can have any number of named exports, but at most one default export.
Named imports must be destructured with matching names inside curly braces (or renamed with 'as'); default imports use no curly braces and can be named freely.
Every module has its own top-level scope — declarations are private to that file unless explicitly exported.
Static import/export statements must be at the top level of a file and are resolved before the module's code runs.
Dynamic import() is a function call usable anywhere in code and always returns a Promise resolving to the module's exports.
Where this shows up
Virtually every modern JavaScript codebase — React apps, Node.js backends, and large browser applications like EngiLipi's own lesson platform — is organized into dozens or hundreds of small modules, each exporting a focused piece of functionality (a component, a utility, an API client) that other files import as needed, instead of one massive file.
import { greet } from './utils.js'; // greet was a default export import greet from './utils.js'; // no curly braces for a default export
A default export is imported without curly braces, under any name the importer chooses. Trying to import it with curly braces (as if it were a named export) results in an undefined value or an import error, depending on the environment.
export default function a() {} export default function b() {} // two defaults in one file export default function a() {} export function b() {} // only one default export allowed
A module can have at most one default export. Declaring a second one is a syntax error — additional exports from the same file must be named exports instead.
[Multiple Choice] How many default exports can a single ES module file have? A. Unlimited B. At most one C. Exactly one, mandatory D. Zero — default exports are deprecated
B. At most one — A module may have at most one default export (it can also have zero, if it only uses named exports). Attempting a second 'export default' in the same file is a syntax error.
[True / False] Variables declared in one module automatically become available as globals in every other module that imports it. A. True B. False
B. False — False. Each module has its own private top-level scope. Only values explicitly marked with export become available to other modules, and only through an explicit import statement.
Create two files in your head: colors.js, which has named exports RED and BLUE (string hex codes) and a default export function mixColors(a, b). Then write app.js that imports the default export as mix and the two named colors, and logs mix(RED, BLUE).
// colors.js export const RED = '#FF0000'; export const BLUE = '#0000FF'; export default function mixColors(a, b) { return mix(${a}, ${b}); } // app.js import mix, { RED, BLUE } from './colors.js'; console.log(mix(RED, BLUE)); // 'mix(#FF0000, #0000FF)'
Combine a default import and named imports in one import statement: import mix, { RED, BLUE } from './colors.js';