Skip to main content

Module System

JavaScript's module system provides a way to split code into files and reuse it. Two major systems coexist: ESM (ES Modules) and CJS (CommonJS).


Why Modules Are Necessary

// Without modules, everything is in the global scope:
// file1.js
var name = 'Alice';
function greet() { return `Hello, ${name}!`; }

// file2.js
var name = 'Bob'; // overwrites file1.js's name!
console.log(greet()); // Hello, Bob! (unintended result)

// With modules:
// file1.mjs - has its own scope
const name = 'Alice';
export function greet() { return `Hello, ${name}!`; }

// file2.mjs - has its own scope
const name = 'Bob';
import { greet } from './file1.mjs';
console.log(greet()); // Hello, Alice! (intended result)
console.log(name); // Bob (this file's name)

ESM (ES Module): import/export

Named Export

// math.mjs
// export at the time of declaration
export const PI = 3.14159;
export let version = '1.0';

export function add(a, b) { return a + b; }
export function subtract(a, b) { return a - b; }

export class Vector {
constructor(x, y) {
this.x = x;
this.y = y;
}

add(other) {
return new Vector(this.x + other.x, this.y + other.y);
}
}

// Or export all at once after declaration
const multiply = (a, b) => a * b;
const divide = (a, b) => a / b;

export { multiply, divide };

// Rename on export
export { multiply as mul, divide as div };

Named Import

// app.mjs
import { PI, add, Vector } from './math.mjs';

console.log(PI); // 3.14159
console.log(add(2, 3)); // 5
const v = new Vector(1, 2);

// Rename on import
import { add as sum, subtract as minus } from './math.mjs';
console.log(sum(1, 2)); // 3

// Import entire module as namespace
import * as math from './math.mjs';
console.log(math.PI);
console.log(math.add(2, 3));
math.version = '2.0'; // Note: can be changed since it's a live binding

Default Export

// user.mjs
// Only one per file
export default class User {
constructor(name, email) {
this.name = name;
this.email = email;
}

toString() {
return `${this.name} <${this.email}>`;
}
}

// Anonymous functions are also allowed
export default function(data) {
return JSON.stringify(data, null, 2);
}
// Default import can be named freely
import User from './user.mjs';
import stringify from './user.mjs'; // same file, different name

const user = new User('Alice', 'alice@example.com');
console.log(stringify({ name: 'test' }));

// Using named and default together
export default class Component { }
export const version = '1.0';
export function helper() { }

import Component, { version, helper } from './component.mjs';

Re-export

// utils/index.mjs - export from multiple modules in one place (Barrel Export)

// named re-export
export { add, subtract } from './math.mjs';
export { User, createUser } from './user.mjs';
export { fetchData, postData } from './api.mjs';

// re-export with rename
export { default as utils } from './utils.mjs';

// default re-export
export { default } from './main.mjs';

// full re-export
export * from './math.mjs';
export * from './string-utils.mjs';

// Usage
import { add, User, fetchData } from './utils/index.mjs';
// No need to import from multiple files individually

Dynamic import(): Code Splitting

// Static import: only at file top level, always loaded
import { heavyModule } from './heavy.mjs'; // always loaded

// Dynamic import: load only when needed
async function loadFeature() {
const module = await import('./heavy-feature.mjs');
module.initialize();
}

// Conditional loading
async function loadLocale(lang) {
const locale = await import(`./locales/${lang}.mjs`);
return locale.default;
}

// Event-based loading (routing)
button.addEventListener('click', async () => {
const { Modal } = await import('./components/Modal.mjs');
const modal = new Modal({ title: 'Confirm', content: 'Do you want to continue?' });
modal.show();
});

// Conditional polyfill loading
if (!window.IntersectionObserver) {
await import('intersection-observer'); // only when needed
}

// Returns a Promise (can be used without await)
import('./analytics.mjs').then(({ track }) => {
track('page_view', { path: location.pathname });
});

// Parallel dynamic loading of multiple modules
const [chartLib, mapLib] = await Promise.all([
import('chart.js'),
import('leaflet')
]);

import.meta

// Access current module metadata
console.log(import.meta.url); // file:///path/to/current/module.mjs

// Resolve relative paths based on current file
const dataUrl = new URL('./data.json', import.meta.url);
const response = await fetch(dataUrl);

// Replacement for __dirname in Node.js
import { fileURLToPath, URL } from 'node:url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = fileURLToPath(new URL('.', import.meta.url));

// Environment variables in Vite, Webpack, and other bundlers
console.log(import.meta.env.MODE); // 'development' or 'production'
console.log(import.meta.env.VITE_API_URL);

// import.meta.resolve (Node.js 20.6+)
const resolvedPath = import.meta.resolve('./utils.mjs');

CJS (CommonJS): require/module.exports

Node.js's traditional module system.

// math.js (CJS)
const PI = 3.14159;

function add(a, b) { return a + b; }
function subtract(a, b) { return a - b; }

// Export all at once
module.exports = { PI, add, subtract };

// Or individually
module.exports.PI = PI;
module.exports.add = add;

// exports is an alias for module.exports
exports.multiply = (a, b) => a * b;
// Warning: exports = {...} changes the reference and won't work!
// app.js (CJS)
const { PI, add } = require('./math.js');
const math = require('./math.js');

console.log(PI);
console.log(add(1, 2));
console.log(math.subtract(5, 3));

// Default export pattern
// user.js
class User { }
module.exports = User; // export the class itself

// Usage
const User = require('./user.js');
const user = new User();

// Built-in modules
const fs = require('node:fs');
const path = require('node:path');
const http = require('node:http');

CJS Characteristics

// CJS is synchronous and evaluated at runtime
const moduleName = getModuleName(); // can determine path dynamically
const module = require(moduleName); // OK

// require is cached
const a = require('./counter.js');
const b = require('./counter.js');
console.log(a === b); // true (same instance)

// counter.js
let count = 0;
module.exports = {
increment() { count++; },
getCount() { return count; }
};

// Side effect: two files share the same counter instance
const c1 = require('./counter.js');
const c2 = require('./counter.js');
c1.increment();
console.log(c2.getCount()); // 1 (same instance!)

ESM vs CJS Differences

FeatureESMCJS
Syntaximport/exportrequire/module.exports
LoadingStatic (compile-time analysis)Dynamic (runtime)
ExecutionCan be asyncAlways synchronous
ScopeModule scopeModule scope
this (top-level)undefinedmodule.exports
Extension.mjs or "type": "module".cjs or default
BrowserNative supportRequires bundler
// Set type via package.json
// { "type": "module" } → .js files treated as ESM
// { "type": "commonjs" } → .js files treated as CJS (default)

// Can mix both
// file.mjs → always ESM
// file.cjs → always CJS
// file.js → follows package.json type field

Tree Shaking

An optimization technique where bundlers remove unused code.

// utils.mjs
export function usedFunction() { return 'used'; }
export function unusedFunction() { return 'unused'; } // this will be removed from the bundle

// app.mjs
import { usedFunction } from './utils.mjs';
// unusedFunction is not imported so it's removed from the bundle

// Why tree shaking works: ESM's static analysis
// import statements are always at the top of the file, no conditional/dynamic imports → bundlers can pre-analyze

// CJS is difficult to tree shake
const utils = require('./utils'); // evaluated at runtime, bundler can't pre-analyze
utils.used();

Side Effect Control

// package.json
{
"sideEffects": false, // all files are pure (no side effects)
"sideEffects": ["*.css", "./src/polyfills.js"] // only specific files have side effects
}

// Example of side-effect code (effect occurs just from importing)
// polyfills.js
Array.prototype.at ??= function(i) { /* polyfill */ };

// CSS module
import './global.css'; // applying CSS is a side effect

Using Modules in the Browser and Node.js

Browser

<!-- Enable ESM with type="module" -->
<script type="module" src="./app.mjs"></script>

<!-- Inline module -->
<script type="module">
import { Component } from './component.mjs';
const app = new Component(document.getElementById('app'));
</script>

<!-- Modules are deferred by default (run after DOM parsing) -->
<!-- Modules are always in strict mode -->
<!-- Modules are loaded only once (cached) -->

<!-- Import Maps: support bare imports in the browser -->
<script type="importmap">
{
"imports": {
"lodash": "https://cdn.skypack.dev/lodash",
"react": "https://esm.sh/react@18"
}
}
</script>

<script type="module">
import _ from 'lodash'; // using import map
import React from 'react';
</script>

Node.js

// Three ways to use ESM in Node.js:

// 1. File extension .mjs
// my-module.mjs
export const greeting = 'Hello from ESM';

// 2. "type": "module" in package.json
// { "type": "module" }
// .js files are now treated as ESM

// 3. .mts (TypeScript)

// Node.js 22+: can import ESM from CJS
const { foo } = await import('./esm-module.mjs');

// Cannot use require in ESM (use createRequire instead)
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const cjsModule = require('./cjs-module.js');

Real-world: Module Structure Design

src/
├── features/
│ ├── user/
│ │ ├── user.model.mjs
│ │ ├── user.service.mjs
│ │ ├── user.api.mjs
│ │ └── index.mjs ← Barrel export
│ └── product/
│ ├── product.model.mjs
│ └── index.mjs
├── shared/
│ ├── utils/
│ │ ├── string.mjs
│ │ ├── date.mjs
│ │ └── index.mjs
│ └── constants.mjs
└── app.mjs
// features/user/index.mjs (Barrel)
export { User } from './user.model.mjs';
export { UserService } from './user.service.mjs';
export * from './user.api.mjs';

// app.mjs
import { User, UserService } from './features/user/index.mjs';
import { formatDate } from './shared/utils/index.mjs';

Expert Tips

1. Handling Circular Dependencies

// a.mjs
import { b } from './b.mjs';
export const a = 'A';
console.log(b); // undefined! (b.mjs tries to load a.mjs first)

// Solution: extract to a shared module or use lazy import
async function getB() {
const { b } = await import('./b.mjs');
return b;
}

2. Module Singleton Pattern

// singleton.mjs
let instance = null;

export function getInstance() {
if (!instance) {
instance = createExpensiveObject();
}
return instance;
}

// ESM runs only once automatically (caching)
// Simplified version:
export const config = await loadConfig(); // Top-level await
// Importing this module always returns the same config instance

3. Conditional Polyfill Loading

// Dynamically load only the polyfills you need
const polyfills = [];

if (!('structuredClone' in globalThis)) {
polyfills.push(import('core-js/stable/structured-clone'));
}

if (!('at' in Array.prototype)) {
polyfills.push(import('core-js/stable/array/at'));
}

await Promise.all(polyfills);
// All polyfills are now loaded