JavaScript · lesson 17 of 24
Beginner
11- 1 Introduction to JavaScript
- 2 Setting Up JavaScript Development Environment
- 3 Variables in JavaScript: var, let, and const
- 4 JavaScript Data Types
- 5 JavaScript Operators
- 6 Control Flow in JavaScript
- 7 Functions in JavaScript
- 8 Working with Strings in JavaScript
- 9 Arrays in JavaScript
- 10 Objects in JavaScript
- 18 DOM Manipulation in JavaScript
Intermediate
10- 11 Error Handling in JavaScript
- 12 Object-Oriented Programming in JavaScript
- 13 Prototypes and Prototype Chain in JavaScript
- 14 Closures in JavaScript
- 15 JavaScript Event Loop Explained
- 16 Asynchronous JavaScript: Callbacks, Promises, and Async/Await
- 17 JavaScript Modules: ESM and CommonJS
- 19 Browser Web APIs in JavaScript
- 20 Testing JavaScript with Jest and Vitest
- 24 JavaScript Interview Preparation: Top 35 Questions
Advanced
3Also part of: TypeScript
JavaScript Modules: ESM and CommonJS
Understand ES modules and CommonJS, their syntax differences, dynamic imports for code splitting, and how bundlers like Vite and webpack fit in.
By SkillByExample · Updated
A module is a file with its own scope that chooses what to share. JavaScript has two module systems: ES modules (import/export, the standard) and CommonJS (require/module.exports, Node’s original). Every example below is a set of real files run with node; Node treats .mjs files as ES modules and .cjs files as CommonJS regardless of any config.
Named Exports
A module can export any number of values by name, and importers pick the ones they want.
// math.mjs
export const PI = 3.14159265358979;
export function add(a, b) { return a + b; }
export function subtract(a, b) { return a - b; }
const secret = "not exported";
console.log("math.mjs evaluated");
// app.mjs
import { add, PI } from "./math.mjs";
import { add as plus } from "./math.mjs";
import * as MathUtils from "./math.mjs";
console.log(add(2, 3), plus(2, 3), PI.toFixed(2));
console.log(Object.keys(MathUtils));
console.log(MathUtils.secret);
$ node app.mjs
math.mjs evaluated
5 5 3.14
[ 'PI', 'add', 'subtract' ]
undefined
math.mjs was imported three times but evaluated once — modules are singletons, cached after the first load. as renames on import (useful when two modules export the same name), and import * as collects every export into a namespace object. secret isn’t in it: anything not exported is private to the file.
Default Exports
A module can also have one default export — its main value — which the importer names freely.
// logger.mjs
export default class Logger {
constructor(prefix) { this.prefix = prefix; }
log(msg) { console.log(`[${this.prefix}] ${msg}`); }
}
export const LEVELS = ["info", "error"];
// main.mjs
import Logger, { LEVELS } from "./logger.mjs";
import WhateverIWant from "./logger.mjs";
import * as ns from "./logger.mjs";
new Logger("App").log(`started with levels ${LEVELS.join("/")}`);
console.log(Logger === WhateverIWant, Logger === ns.default);
$ node main.mjs
[App] started with levels info/error
true true
The default export is really just an export named default — ns.default is the same class. Because each importer picks the name, a default export can be imported under different names in different files, which makes renames and “find all usages” harder. Many teams prefer named exports for that reason.
Re-exports (Barrel Files)
An index file can re-export from several modules so consumers have one import path.
// utils/string.mjs
export const capitalize = (s) => s[0].toUpperCase() + s.slice(1);
// utils/index.mjs
export { add, PI } from "../math.mjs";
export { capitalize } from "./string.mjs";
export { default as Logger } from "../logger.mjs";
// barrel.mjs
import { capitalize } from "./utils/index.mjs";
console.log(capitalize("only capitalize was imported"));
$ node barrel.mjs
math.mjs evaluated
Only capitalize was imported
The consumer doesn’t know or care which file each name lives in. Notice math.mjs evaluated printed even though barrel.mjs only asked for capitalize: importing from a barrel loads every module the barrel re-exports. That’s harmless here, but in a large library a barrel can drag in far more code than you use — which is the problem tree shaking (below) solves at build time.
CommonJS
CommonJS assigns the public API to module.exports and loads it with a synchronous require().
// counter.cjs
let count = 0;
function increment() { count += 1; }
module.exports = { count, increment, getCount: () => count };
// counter.mjs
export let count = 0;
export function increment() { count += 1; }
// compare.mjs
import cjs from "./counter.cjs";
import { count, increment } from "./counter.mjs";
cjs.increment();
cjs.increment();
console.log("CommonJS count:", cjs.count, "| getCount():", cjs.getCount());
increment();
increment();
console.log("ESM count:", count);
$ node compare.mjs
CommonJS count: 0 | getCount(): 2
ESM count: 2
This is the deepest difference between the two systems. module.exports = { count } copied the value 0 into an object; incrementing the module’s variable later doesn’t change the copy, so only a getter sees 2. An ES module export is a live binding — the importer’s count is a read-only view of the exporting module’s variable, so it shows 2. Also note ESM can import a CommonJS file: its module.exports object arrives as the default export.
Going the Other Way: require() an ES Module
Since Node 22.12, CommonJS can require() an ES module too, as long as it has no top-level await.
// legacy.cjs
const math = require("./math.mjs");
const { add } = require("./math.mjs");
console.log(add(20, 22), typeof math.subtract);
const { default: Logger } = require("./logger.mjs");
new Logger("cjs").log("default export via .default");
$ node legacy.cjs
math.mjs evaluated
42 function
[cjs] default export via .default
require returns the module’s namespace object, so a default export has to be read from .default. Older Node versions threw ERR_REQUIRE_ESM here, which is why so many packages used to ship two builds.
| ES modules | CommonJS | |
|---|---|---|
| Syntax | import / export | require() / module.exports |
| Bindings | live | copied values |
| Loading | static, resolved before running | runs when require() is called |
Top-level await | yes | no |
| Browsers | native | needs a bundler |
| Node file extensions | .mjs, or .js with "type": "module" | .cjs, or .js by default |
To make plain .js files ES modules for a whole package, add "type": "module" to its package.json; any CommonJS file in that package then needs the .cjs extension.
Dynamic import()
import() is an expression that loads a module on demand and returns a promise for its namespace.
// heavy-chart.mjs
console.log(" (heavy-chart.mjs loading now)");
export function renderChart(data) { return data.map((n) => "#".repeat(n)).join("\n"); }
// lazy.mjs
console.log("app started");
async function onOpenChartTab(data) {
const { renderChart } = await import("./heavy-chart.mjs");
return renderChart(data);
}
console.log(await onOpenChartTab([3, 5, 2]));
console.log("second open:");
console.log(await onOpenChartTab([1]));
const page = "barrel";
await import(`./${page}.mjs`);
$ node lazy.mjs
app started
(heavy-chart.mjs loading now)
###
#####
##
second open:
#
math.mjs evaluated
Only capitalize was imported
The chart module didn’t load at startup — only when first needed — and the second import() reused the cached module, so it didn’t log again. The specifier can be computed at runtime (./${page}.mjs), something a static import declaration can never do. In the browser, bundlers turn each dynamic import() into a separate file that is downloaded only when that line runs: code splitting.
What a Bundler Does With This
For browsers you usually run a bundler such as Vite (or webpack, Rollup, esbuild). Here is a real Vite build of a two-module app:
// shop/math.js
export const add = (a, b) => a + b;
export const subtractUnusedEverywhere = (a, b) => a - b;
// shop/main.js
import { add } from "./math.js";
document.body.textContent = `total: ${add(2, 3)}`;
document.body.onclick = async () => {
const { renderChart } = await import("./chart.js");
document.body.textContent = renderChart([3, 5]);
};
// shop/chart.js
export const renderChart = (data) => data.map((n) => "#".repeat(n)).join(" ");
<!-- shop/index.html -->
<script type="module" src="/main.js"></script>
$ cd shop && npx vite build --logLevel warn && ls dist/assets && grep -c subtractUnusedEverywhere dist/assets/*.js
chart-DvOuFD-a.js
index-Bqh_kbet.js
dist/assets/chart-DvOuFD-a.js:0
dist/assets/index-Bqh_kbet.js:0
Two things happened. The dynamically imported chart.js became its own file, fetched only when the user clicks. And subtractUnusedEverywhere appears nowhere in the output (grep -c counts 0 in both files): because ES imports are static, the bundler could prove nothing uses it and dropped it — tree shaking. With CommonJS’s runtime require() calls that proof isn’t possible, which is why browser-bound code should use ES modules. (The hashes in the file names change whenever the content does, so browsers can cache the files forever.)
Key Takeaways
- ES modules are the standard:
import/export, evaluated once and cached. - Anything not exported is private to the file.
- ESM exports are live bindings; CommonJS
module.exportsholds copied values. .mjsis always ESM and.cjsalways CommonJS;"type": "module"decides what.jsmeans.import()loads on demand and is how bundlers split code; static imports let them tree-shake.
Practice
1. Add a multiply export to a module and import it under the name times.
// ops.mjs
export const multiply = (a, b) => a * b;
// p1.mjs
import { multiply as times } from "./ops.mjs";
console.log(times(6, 7));
$ node p1.mjs
42
2. Give the CommonJS counter a live-looking count using a getter, so cjs.count reports 2.
// counter2.cjs
let count = 0;
module.exports = {
increment() { count += 1; },
get count() { return count; },
};
// p2.cjs
const counter = require("./counter2.cjs");
counter.increment();
counter.increment();
console.log(counter.count);
$ node p2.cjs
2
A getter re-reads the variable on every access — the CommonJS way to imitate a live binding.
3. Choose a formatter module at runtime from a variable, using import().
// fmt-upper.mjs
export default (s) => s.toUpperCase();
// fmt-reverse.mjs
export default (s) => [...s].reverse().join("");
// p3.mjs
for (const style of ["upper", "reverse"]) {
const { default: format } = await import(`./fmt-${style}.mjs`);
console.log(style, "->", format("modules"));
}
$ node p3.mjs
upper -> MODULES
reverse -> seludom
With import() the default export arrives as the default property of the namespace object, so destructure it and rename it.
4. Prove a module is evaluated only once even when two other modules import it.
// shared.mjs
console.log("shared.mjs evaluated");
export const createdAt = Math.random();
// a.mjs
export { createdAt as a } from "./shared.mjs";
// b.mjs
export { createdAt as b } from "./shared.mjs";
// p4.mjs
import { a } from "./a.mjs";
import { b } from "./b.mjs";
console.log("same value:", a === b);
$ node p4.mjs
shared.mjs evaluated
same value: true
One evaluation and one random number, shared by both importers — the property the Singleton pattern relies on.