ES Modules

โ–ถ Try It Yourself

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)
Note: ES Modules are static โ€” all 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.
Tip: Use barrel files (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.
Warning: Module imports are live bindings โ€” not value copies. If a module exports 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

▶ Try It Yourself

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>

🧠 Test Yourself

You need to import a module only when a button is clicked to avoid loading it upfront. Which approach should you use?





โ–ถ Try It Yourself