ES Modules
ES Modules
ES Modules (ESM) are the official, standardized module system built into JavaScript since ES2015. They allow you to split code across multiple files and explicitly control what is exported and imported.
Why Modules?
Before modules, all JavaScript ran in a single global scope. Variables from one file could collide with another. Modules give each file its own scope — nothing leaks unless you explicitly export it.
Named Exports
Export as many things as you like from a file:
// math.js
export const PI = 3.14159;
export function add(a, b) { return a + b; }
export class Vector { ... }Import specific names:
import { PI, add } from "./math.js";Default Exports
Each file can have one default export — typically the main thing the module represents:
// greet.js
export default function greet(name) {
return `Hello, ${name}!`;
}import greet from "./greet.js"; // any name works for default
import sayHi from "./greet.js"; // also validCombining Named and Default
import defaultExport, { namedOne, namedTwo } from "./module.js";Aliasing with as
import { add as sum } from "./math.js";
import * as MathUtils from "./math.js"; // namespace importRe-exports
Re-export from a module without importing first:
export { add, PI } from "./math.js";
export { default as greet } from "./greet.js";This is common in index.js barrel files that aggregate a library's public API.
Dynamic import()
import() is a function that returns a Promise, enabling lazy loading:
const module = await import("./heavy.js");
module.default();Dynamic imports are critical for code-splitting in bundlers like Webpack and Vite — only load code when it is actually needed.
Module Characteristics
- Modules are strict mode by default — no need for "use strict"
- Modules are singletons — the same module imported twice returns the same cached instance
- Top-level
awaitis allowed in modules
Code Examples
// Simulating module exports in Node.js (CommonJS-compatible illustration)
// --- math.js ---
const PI = 3.14159265;
function add(a, b) { return a + b; }
function multiply(a, b) { return a * b; }
function square(n) { return n * n; }
// Named exports: { PI, add, multiply, square }
// Default export: the add function
// --- main.js (consumer) ---
// import { PI, add, multiply } from './math.js';
// import square from './math.js'; // default
// Demonstrated inline:
const math = { PI, add, multiply, square };
const { add: sum, multiply: mul } = math;
console.log("PI:", math.PI);
console.log("add(3, 4):", sum(3, 4));
console.log("multiply(6, 7):", mul(6, 7));
console.log("square(9):", math.square(9));Named exports allow multiple exports per file. Consumers pick exactly what they need. Aliasing with 'as' avoids naming conflicts and allows renaming on import.
// Dynamic import returns a Promise
// Useful for loading heavy code only when needed
async function loadChartLibrary() {
console.log("Loading chart module...");
// In a real app: const { Chart } = await import('./chart.js');
// Simulated here:
const chartModule = await Promise.resolve({
default: class Chart {
constructor(data) { this.data = data; }
render() { return `Chart with ${this.data.length} points`; }
}
});
return chartModule.default;
}
async function main() {
console.log("App started");
// Chart only loaded when this branch is hit
const userWantsChart = true;
if (userWantsChart) {
const Chart = await loadChartLibrary();
const chart = new Chart([1, 2, 3, 4, 5]);
console.log(chart.render());
}
console.log("App running");
}
main();Dynamic import() loads a module asynchronously, returning a Promise that resolves to the module object. This enables code-splitting — heavy dependencies are only fetched when actually needed, improving initial load time.
// Simulating a barrel file (index.js) pattern
// --- utils/string.js ---
const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1);
const truncate = (s, n) => s.length > n ? s.slice(0, n) + "..." : s;
// --- utils/number.js ---
const clamp = (n, min, max) => Math.min(Math.max(n, min), max);
const round2 = (n) => Math.round(n * 100) / 100;
// --- utils/index.js (barrel) ---
// export { capitalize, truncate } from './string.js';
// export { clamp, round2 } from './number.js';
// Consumer: import * as Utils from './utils/index.js';
const Utils = { capitalize, truncate, clamp, round2 };
console.log(Utils.capitalize("hello world"));
console.log(Utils.truncate("A very long title here", 12));
console.log(Utils.clamp(150, 0, 100));
console.log(Utils.round2(3.14159));A barrel file (index.js) re-exports from multiple sub-modules, giving consumers a single import point. Namespace imports (import * as Utils) collect all exports under one object.
Quick Quiz
1. How many default exports can a single module file have?
2. What does import * as Utils from './utils.js' do?
3. What does import() (dynamic import) return?
Was this lesson helpful?