ES Modules โ introduced in ES6 and now natively supported in all modern browsers and Node.js โ are the official JavaScript module system. They let you split code across files with explicit import and export declarations, enabling tree-shaking, lazy loading, and clean dependency management. Modules solve the problems of global namespace pollution, dependency ordering, and code organisation that plagued pre-module JavaScript. In this lesson you will master named exports, default exports, barrel files, dynamic imports, and the module characteristics every developer needs to know.
Export and Import Syntax
| Pattern | Export | Import |
|---|---|---|
| Named export | export const PI = 3.14 |
import { PI } from './math.js' |
| Named function | export function add(a, b) {} |
import { add } from './math.js' |
| Export list | export { add, subtract } |
import { add, subtract } from './math.js' |
| Rename on export | export { fn as helper } |
import { helper } from './module.js' |
| Rename on import | export { add } |
import { add as sum } from './math.js' |
| Default export | export default class App {} |
import App from './App.js' |
| Namespace import | (any named exports) | import * as Utils from './utils.js' |
| Re-export (barrel) | export { add } from './math.js' |
import { add } from './index.js' |
| Dynamic import | export default fn |
const mod = await import('./mod.js') |
Named vs Default Export
| Feature | Named Export | Default Export |
|---|---|---|
| Per module count | Many allowed | One only |
| Import syntax | { } โ name must match export |
No { } โ any name you choose |
| Best for | Utility files with multiple exports | One primary thing per file (component, class) |
| Tree-shaking | Bundlers can eliminate unused named exports | Slightly harder to tree-shake |
Module Characteristics
| Feature | Behaviour |
|---|---|
| Strict mode | Always on โ no need for 'use strict' |
| Scope | Module scope โ top-level const is NOT global |
| Cached | Loaded and executed once โ same instance shared everywhere |
| Static imports | Resolved at parse time โ cannot be inside if or functions |
| Dynamic import() | Returns a Promise โ usable anywhere, enables lazy loading |
| Live bindings | Imported values are live references โ not value copies |
| this at top level | undefined (not window) |
import statements are resolved at parse time before any code executes. This means you cannot place import inside an if block, a function, or a loop. This constraint enables static analysis and tree-shaking. For conditional or deferred loading, use dynamic import(), which returns a Promise and can appear anywhere.index.js) to create a clean public API for a folder: re-export everything the outside world needs from one file. Consumers import from './components' instead of './components/Button/Button.js'. This also lets you reorganise the internal file structure without breaking every import in the codebase.let count = 0 and later increments it, any file that imported count sees the updated value. This differs from CommonJS (require()), which gives you a snapshot. Live bindings enable reactive patterns but can surprise developers who expect value copies.Basic Example
// โโ math.js โ named exports โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
export const PI = 3.14159265;
export function add(a, b) { return a + b; }
export function subtract(a, b) { return a - b; }
export function multiply(a, b) { return a * b; }
export function divide(a, b) {
if (b === 0) throw new RangeError('Division by zero');
return a / b;
}
const square = n => n ** 2;
const cube = n => n ** 3;
export { square, cube };
// โโ app.js โ named imports โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
import { PI, add, multiply, square } from './math.js';
import { subtract as minus } from './math.js'; // renamed
console.log(PI); // 3.14159265
console.log(add(3, 4)); // 7
console.log(minus(10, 3)); // 7
console.log(square(5)); // 25
// โโ user.js โ default export โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
export default class User {
constructor(name, email) {
this.name = name;
this.email = email;
this.id = crypto.randomUUID();
}
toJSON() { return { id: this.id, name: this.name, email: this.email }; }
static fromJSON({ name, email }) { return new User(name, email); }
}
// โโ main.js โ mix default + named imports โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
import User from './user.js'; // default โ any name you choose
import { add, PI } from './math.js'; // named โ must match export
const alice = new User('Alice', 'alice@example.com');
console.log(alice.toJSON());
// โโ utils/index.js โ barrel file โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
export { add, subtract, multiply, divide, PI } from './math.js';
export { default as User } from './user.js';
export { formatDate, formatCurrency } from './format.js';
// โโ consumer.js โ clean import from barrel โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
import { add, User, formatDate } from './utils/index.js';
// One import instead of three separate path-specific imports
// โโ Namespace import โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
import * as MathUtils from './math.js';
console.log(MathUtils.add(2, 3)); // 5
console.log(MathUtils.PI); // 3.14159265
// โโ Dynamic import โ lazy loading on demand โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
async function loadChartLibrary() {
// Only fetched and parsed when this function is called
const { Chart } = await import('./chart.js');
return new Chart(document.querySelector('#canvas'));
}
// Conditional loading โ can use variables in the path
async function loadEditor(type) {
const module = await import(`./editors/${type}-editor.js`);
return module.default; // get the default export
}
// Code splitting โ load heavy libraries only when needed
document.querySelector('#export-btn').addEventListener('click', async () => {
const { generatePDF } = await import('./pdf-generator.js');
await generatePDF(document.body);
});
// โโ Using modules in HTML โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// <script type="module" src="main.js"></script>
// type="module" enables:
// - import / export syntax
// - Deferred by default (loads after HTML parsed)
// - Strict mode automatic
// - Module scope โ no global pollution
// - CORS required โ needs HTTP server, not file://
How It Works
Step 1 โ Static Analysis Before Execution
Before any code runs, the JavaScript engine reads all import and export declarations and builds a module dependency graph. This allows bundlers (Vite, Webpack, Rollup) to eliminate unused exports (tree-shaking) and browsers to begin fetching dependent module files in parallel, improving startup performance.
Step 2 โ Module Scope Eliminates Global Pollution
In a regular <script>, top-level const x = 5 creates a global accessible as window.x. In a module, const x = 5 is scoped to that file โ invisible to others unless explicitly exported. This eliminates the naming collisions that plagued pre-module JavaScript.
Step 3 โ Modules Execute Once and Are Cached
No matter how many files import from ./math.js, it is fetched, parsed, and executed exactly once. All importers receive the same module instance. Module-level state โ a database connection, a configuration object, a singleton store โ is naturally shared everywhere without any extra work.
Step 4 โ Live Bindings vs CommonJS Copies
ES module exports are live references to the original variables. If math.js exports let count = 0 and later does count++, all importers see the new value. CommonJS require() copies values at require time. Live bindings enable reactive patterns and are important to understand when module state changes.
Step 5 โ Dynamic import() Enables Code Splitting
import('./module.js') is a function call that returns a Promise resolving to the module’s export object. It can appear anywhere โ inside event handlers, async functions, or conditionals. This lets bundlers split your application into smaller chunks loaded on demand, rather than one giant bundle loaded upfront.
Real-World Example: Plugin System with Dynamic Imports
// plugin-manager.js
class PluginManager {
#plugins = new Map();
#hooks = new Map();
async register(name, path) {
if (this.#plugins.has(name)) {
console.warn(`Plugin "${name}" already registered`);
return;
}
try {
const module = await import(path);
const plugin = module.default;
if (typeof plugin?.install !== 'function') {
throw new TypeError(`Plugin "${name}" must have an install() method`);
}
await plugin.install(this);
this.#plugins.set(name, plugin);
console.log(`Plugin "${name}" registered`);
} catch (err) {
console.error(`Failed to load plugin "${name}":`, err.message);
}
}
addHook(event, fn) {
if (!this.#hooks.has(event)) this.#hooks.set(event, []);
this.#hooks.get(event).push(fn);
return () => {
const hooks = this.#hooks.get(event);
const idx = hooks.indexOf(fn);
if (idx !== -1) hooks.splice(idx, 1);
};
}
async applyHooks(event, data) {
const fns = this.#hooks.get(event) ?? [];
let result = data;
for (const fn of fns) result = await fn(result);
return result;
}
has(name) { return this.#plugins.has(name); }
listPlugins() { return [...this.#plugins.keys()]; }
}
// โโ plugins/markdown.js โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
export default {
async install(manager) {
manager.addHook('render', async content =>
content
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/_(.*?)_/g, '<em>$1</em>')
);
}
};
// โโ plugins/sanitize.js โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
export default {
async install(manager) {
// Runs first (registered first) โ sanitize before rendering
manager.addHook('render', async content =>
content.replace(/<script.*?>.*?<\/script>/gis, '')
);
}
};
// โโ Usage โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const manager = new PluginManager();
await manager.register('sanitize', './plugins/sanitize.js');
await manager.register('markdown', './plugins/markdown.js');
const raw = '**Hello** _World_ <script>evil()</script>';
const html = await manager.applyHooks('render', raw);
console.log(html);
// <strong>Hello</strong> <em>World</em> (script removed)
console.log(manager.listPlugins()); // ['sanitize', 'markdown']
Common Mistakes
Mistake 1 โ Static import inside a block
โ Wrong โ SyntaxError at parse time:
if (isDev) {
import { debug } from './debug.js'; // SyntaxError
}
โ Correct โ use dynamic import for conditional loading:
if (isDev) {
const { debug } = await import('./debug.js');
}
Mistake 2 โ Wrong import syntax for default vs named
โ Wrong โ curly braces for a default export:
import { User } from './user.js'; // SyntaxError if User is a default export
โ Correct โ no braces for default, braces for named:
import User from './user.js'; // default export โ no braces
import { add, PI } from './math.js'; // named exports โ with braces
Mistake 3 โ Trying to run module scripts via file://
โ Wrong โ browsers block module imports over file:// due to CORS:
// Opening index.html directly as a file โ modules will fail with CORS error
โ Correct โ always serve via HTTP, even locally:
// npx serve . โ simple static server
// npx vite โ full dev server with HMR
// python -m http.server 3000 โ Python quick server
Quick Reference
| Task | Code |
|---|---|
| Named export | export const fn = () => {} |
| Default export | export default class MyClass {} |
| Named import | import { fn, PI } from './module.js' |
| Default import | import MyClass from './module.js' |
| Rename import | import { fn as helper } from './module.js' |
| Namespace import | import * as Utils from './utils.js' |
| Barrel re-export | export { fn } from './module.js' |
| Dynamic import | const mod = await import('./module.js') |
| HTML usage | <script type="module" src="main.js"></script> |