Why Modules?
Before modules, all JavaScript shared a single global scope. Every variable and function was a potential name collision waiting to happen. Modules solve this with encapsulation, reusability, and explicit dependency management.
// Without modules β everything is global (old way)
// math.js (included via <script>)
var add = function(a, b) { return a + b; }; // pollutes global scope
// With modules β private by default
// math.js
const PI = Math.PI; // private β not accessible outside this file
export function add(a, b) { return a + b; } // named export
export function multiply(a, b) { return a * b; } // named export
export const VERSION = '1.0.0'; // export a value
Named Exports
// utils.js
export function formatDate(date) {
return date.toLocaleDateString('en-GB');
}
export function capitalize(str) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
export const MAX_RETRIES = 3;
// Alternative: declare first, export at the end
function debounce(fn, delay) { /* ... */ }
function throttle(fn, limit) { /* ... */ }
export { debounce, throttle }; // export list
// Rename on export
export { debounce as debounceFn };
Default Exports
Each module may have at most one default export. Default exports are typically used for the "main thing" the module provides β a class, a function, or a configuration object.
// logger.js β a single default export
export default class Logger {
constructor(prefix) {
this.prefix = prefix;
}
log(msg) {
console.log(`[${this.prefix}] ${msg}`);
}
}
// api.js β export function as default
export default async function fetchUser(id) {
const res = await fetch(`/api/users/${id}`);
return res.json();
}
// config.js β export an object as default
export default {
baseURL: 'https://api.example.com',
timeout: 5000,
headers: { 'Content-Type': 'application/json' }
};
Importing
// Named imports β must match exported names
import { formatDate, capitalize, MAX_RETRIES } from './utils.js';
// Rename on import with 'as'
import { formatDate as fmt, capitalize as cap } from './utils.js';
// Import everything as a namespace object
import * as Utils from './utils.js';
Utils.formatDate(new Date());
Utils.capitalize('hello');
// Default import β name can be anything
import Logger from './logger.js';
import fetchUser from './api.js';
// Mix default and named imports
import Logger, { LOG_LEVELS } from './logger.js';
// Import for side effects only (e.g., polyfills, initialisation)
import './setup.js'; // runs the module code but imports nothing
// Using imports
const logger = new Logger('App');
logger.log('Server started'); // [App] Server started
const user = await fetchUser(42);
console.log(user.name);
[App] Server started Alice // user.name from fetchUser(42)
Prefer named exports for utility modules with multiple exports. Use a default export when a module has one primary responsibility. Avoid modules that mix many defaults β it makes tree shaking harder and imports confusing.
Re-Exporting and Barrel Files
// components/index.js β "barrel" file that re-exports
export { Button } from './Button.js';
export { Input } from './Input.js';
export { Modal } from './Modal.js';
export { default as Card } from './Card.js'; // re-export default as named
// Now consumers import from one clean path:
import { Button, Modal } from './components/index.js';
// instead of:
// import { Button } from './components/Button.js';
// import { Modal } from './components/Modal.js';
// Re-export everything from a module
export * from './utils.js';
export * from './validators.js';
// Re-export with rename
export { formatDate as fmtDate } from './utils.js';
Dynamic import()
Static import is resolved at parse time. Dynamic import() loads a module at runtime and returns a Promise β perfect for code-splitting and lazy loading.
// Load a module only when needed
async function loadChart() {
const { default: Chart } = await import('./Chart.js');
return new Chart('canvas');
}
// Conditionally load based on feature detection
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
const { applyDarkTheme } = await import('./themes/dark.js');
applyDarkTheme();
}
// Lazy-load on user action
document.getElementById('loadEditor').addEventListener('click', async () => {
const { Editor } = await import('./Editor.js');
const editor = new Editor('#container');
editor.init();
});
// Import with error handling
try {
const module = await import('./optional-feature.js');
module.init();
} catch (err) {
console.warn('Optional feature not available:', err.message);
}
Using Modules in HTML
// In HTML: add type="module"
// <script type="module" src="./main.js"></script>
// Key differences with type="module":
// 1. Deferred by default (runs after DOM is parsed)
// 2. Strict mode is always on
// 3. Has its own scope (no global leakage)
// 4. Can use top-level await
// main.js (type="module")
import { initRouter } from './router.js';
import { setupTheme } from './theme.js';
// Top-level await (ES2022 β works in module scripts)
const config = await fetch('/api/config').then(r => r.json());
initRouter(config.routes);
setupTheme(config.defaultTheme);
CommonJS vs ES Modules
| Feature | CommonJS (CJS) | ES Modules (ESM) |
|---|---|---|
| Syntax | require() / module.exports | import / export |
| Loading | Synchronous (runtime) | Asynchronous (parse time) |
| Static analysis | Not possible | Yes β enables tree shaking |
| Top-level await | No | Yes (ESM only) |
| Default in Node.js | Yes (legacy) | Yes with .mjs or "type":"module" |
| Browser support | No (needs bundler) | Native (modern browsers) |
| Named exports | Via destructure of require() | First-class syntax |
| Circular dependencies | Handled (with caveats) | Handled (but avoid them) |
Because ES module imports and exports are statically analysable, bundlers (Webpack, Rollup, Vite) can determine which exports are actually used and strip unused code from the final bundle. This is called tree shaking and only works with ESM β it cannot work with CommonJS require().
Interview Questions
- What is the difference between a named export and a default export?
- How does dynamic
import()differ from staticimport? - Why do ES Modules enable tree shaking while CommonJS does not?
- What happens when you have circular module dependencies?
- What does
import * as ns from './module.js'do?
ποΈ Practical Exercise
Create a mini math library across two files. In arithmetic.js, export named functions add, subtract, multiply, and divide. In statistics.js, export mean, median, and mode using the arithmetic helpers. Then create an index.js barrel file that re-exports everything from both. Write a consumer script that imports { mean, multiply } from the barrel.
π₯ Challenge Exercise
Build a plugin system using dynamic import(). Define a PluginManager class that accepts plugin names, loads them lazily via import() from a plugins/ directory, registers their init() method, and calls all registered init methods when pluginManager.runAll() is called. Handle loading errors gracefully.
Frequently Asked Questions
- Do I need a bundler to use ES Modules?
- Not for modern browsers or Node.js β both support ESM natively. But for production web apps you still want a bundler (Vite, Webpack, Rollup) for code-splitting, minification, and compatibility.
- Can a file have both a default and named exports?
- Yes. A file can have exactly one default export and any number of named exports. Consumers can import both:
import Default, { named1, named2 } from './module.js'. - Why do module imports need the
.jsextension? - In native ES Modules (browser and Node.js), file extensions are required because the runtime needs to know exactly what URL to fetch. Bundlers like Webpack and Vite often resolve extensions automatically, which is why you can omit them in framework code.
- What is a circular dependency and why is it a problem?
- A circular dependency occurs when module A imports from B, and B imports from A. JavaScript handles this by providing a partially-initialised export object, which can cause variables to be
undefinedwhen accessed. Avoid circular dependencies by restructuring shared code into a third module. - Is
import()supported everywhere? - Yes, in all modern browsers and Node.js 12+. In very old environments, dynamic imports require a polyfill or bundler transformation.
π Summary
- Modules give each file its own scope β nothing leaks to the global namespace.
- Use named exports for multiple exports; default export for the module's primary value.
- Import with exact names for named exports; any name for default exports.
- Use
import * as nsto import everything into a namespace object. - Barrel files (
index.js) consolidate re-exports for cleaner import paths. - Dynamic
import()loads modules at runtime β enables lazy loading and code-splitting. - ESM enables tree shaking because imports are statically analysable at build time.
- Add
type="module"to script tags to use ES Modules in HTML; modules run deferred and in strict mode.