Modules and Tooling
A module is a file with its own scope that explicitly declares what it exports and what it imports. JavaScript has two module systems: ES modules (ESM), the standard, and CommonJS (CJS), Node’s original format that is still everywhere.
ES modules
Section titled “ES modules”Every file is its own scope. Nothing is global unless you export it, and nothing is visible unless you import it.
Named exports
Section titled “Named exports”export const PI = 3.14159;
export function add(a, b) { return a + b;}
export class Vector { constructor(x, y) { this.x = x; this.y = y; }}Or list them at the bottom, which keeps the public surface in one place:
const PI = 3.14159;const add = (a, b) => a + b;const internalHelper = () => {}; // not exported — private to the module
export { PI, add };export { add as sum }; // export under a different nameImporting:
import { PI, add } from './math.js';import { add as sum } from './math.js'; // rename on importimport * as math from './math.js'; // namespace object
add(1, 2); // => 3math.add(1, 2); // => 3math.PI; // => 3.14159Default exports
Section titled “Default exports”One per module, imported without braces under any name you like.
export default function log(msg) { console.log(`[app] ${msg}`);}import log from './logger.js'; // name is yours to chooseimport anyName from './logger.js'; // same functionYou can combine both in one module and one import statement:
export default class ApiClient {}export const VERSION = '1.0';import ApiClient, { VERSION } from './api.js';Re-exporting
Section titled “Re-exporting”A “barrel” file collects a package’s public API:
export { add, PI } from './math.js';export { default as Logger } from './logger.js';export * from './utils.js'; // all named exportsexport * as shapes from './shapes.js'; // as a namespace (ES2020)import { add, Logger, shapes } from './index.js';Imports are hoisted and static
Section titled “Imports are hoisted and static”import and export statements are resolved before any code runs. That is why they must be at the top level — never inside a function, if, or try.
console.log(add(1, 2)); // works — the import already ranimport { add } from './math.js';if (condition) { import { x } from './a.js'; // SyntaxError}This static structure is what enables tree-shaking: a bundler can prove which exports are unused and drop them, because the import graph is known without executing anything.
Live bindings
Section titled “Live bindings”An import is a live read-only view of the exported variable, not a copy of its value. When the exporting module changes it, importers see the change.
export let count = 0;export function increment() { count++; }import { count, increment } from './counter.js';
console.log(count); // => 0increment();console.log(count); // => 1 ← the binding is live
count = 5; // TypeError: Assignment to constant variableOnly the owning module may change an exported binding. This differs sharply from CommonJS, where require copies the value at the moment of the call.
Module scope and single evaluation
Section titled “Module scope and single evaluation”- Top-level
var/functiondo not become properties of the global object. - Modules are always strict mode.
- Top-level
thisisundefined(in a CommonJS file it ismodule.exports). - A module is fetched, parsed, and evaluated once per resolved URL, no matter how many files import it. That is what makes a module-level object a natural singleton.
console.log('config evaluated'); // prints exactly onceexport const config = { loaded: Date.now() };Circular imports do not crash, but a module that reads an imported value before the other module has finished evaluating will see undefined (or hit a TDZ error for let/const). Function declarations survive cycles because they hoist. Prefer restructuring over relying on this.
Dynamic import()
Section titled “Dynamic import()”import() is a function-like form that loads a module at runtime and returns a promise for its namespace object. It works anywhere — inside functions, conditionals, event handlers.
const { default: Chart } = await import('./chart.js');Three real uses:
// 1. Code splitting — load only when neededbutton.addEventListener('click', async () => { const { renderEditor } = await import('./editor.js'); renderEditor();});// 2. Conditional loadingconst locale = navigator.language.startsWith('fr') ? 'fr' : 'en';const messages = await import(`./locales/${locale}.js`);// 3. Optional dependencylet sharp;try { sharp = (await import('sharp')).default;} catch { console.warn('sharp not installed; skipping image optimization');}The result is the namespace object, so a default export lives at .default. Bundlers use import() as the code-splitting boundary: each dynamic import becomes a separate chunk fetched on demand.
Top-level await
Section titled “Top-level await”In an ES module, await is allowed at the top level — no async wrapper needed.
const { createConnection } = await import('./driver.js');export const connection = await createConnection(process.env.DATABASE_URL);Importers automatically wait for that module’s evaluation to complete, so connection is always ready.
CommonJS
Section titled “CommonJS”Node’s original system, still the default for any package without "type": "module" and for every .cjs file.
function add(a, b) { return a + b; }const PI = 3.14159;
module.exports = { add, PI };// or: exports.add = add;const { add, PI } = require('./math.cjs');const math = require('./math.cjs');How it differs from ESM:
| ES modules | CommonJS | |
|---|---|---|
| Syntax | import / export |
require / module.exports |
| Resolution | static, before execution | dynamic, at call time |
| Loading | asynchronous | synchronous |
| Bindings | live | copied value |
| Conditional import | via import() |
require() anywhere |
| Strict mode | always | opt in |
Top-level this |
undefined |
module.exports |
| File extension in path | required | optional |
| Tree-shakeable | yes | not reliably |
The value-copy difference is the one that bites:
let count = 0;module.exports = { count, increment: () => count++ };const { count, increment } = require('./counter.cjs');increment();console.log(count); // => 0 — a snapshot, not a live bindingChoosing the format in Node
Section titled “Choosing the format in Node”Node decides per file:
.mjs→ always ESM..cjs→ always CommonJS..js→ ESM if the nearestpackage.jsonhas"type": "module", otherwise CommonJS.
{ "name": "my-app", "type": "module"}Interop
Section titled “Interop”ESM can import CommonJS. Node treats module.exports as the default export.
import pkg from 'some-cjs-package'; // module.exportsconst { helper } = pkg;Node can often statically detect named exports from simple CJS modules, so import { helper } from 'some-cjs-package' sometimes works — but it is not guaranteed. The default import always works.
CommonJS cannot require() an ES module in older Node versions; use dynamic import(), which works in CJS because it is asynchronous:
(async () => { const { helper } = await import('./esm-module.mjs');})();CJS globals that do not exist in ESM, and their replacements:
// CommonJS // ESM equivalent__dirname import.meta.dirname // Node 20.11+__filename import.meta.filename // Node 20.11+require.resolve('x') import.meta.resolve('x')For older Node versions:
import { fileURLToPath } from 'node:url';import { dirname } from 'node:path';
const __filename = fileURLToPath(import.meta.url);const __dirname = dirname(__filename);import.meta.url is the module’s absolute URL and is the standard way to resolve files relative to the module.
Modules in the browser
Section titled “Modules in the browser”A classic <script> shares one global scope with every other script. type="module" changes that.
<script type="module" src="./app.js"></script>
<script type="module"> import { add } from './math.js'; console.log(add(1, 2));</script>What type="module" gives you:
- Own scope — top-level declarations are not global.
- Strict mode, always.
- Deferred by default — the script downloads in parallel and runs after the HTML is parsed, so
deferis redundant and DOM elements are available. - Executes once, even if the same URL is included twice.
- Fetched with CORS rules, so cross-origin modules need CORS headers.
- Requires a real server;
file://fails on CORS.
# Serve a folder for local module developmentnpx serve .# orpython3 -m http.server 8000Specifiers must be a URL or a relative/absolute path — the browser has no node_modules resolution. Bare names like import 'lodash' fail unless you declare an import map:
<script type="importmap"> { "imports": { "lodash": "https://cdn.jsdelivr.net/npm/lodash-es@4.17.21/lodash.js" } }</script><script type="module"> import { chunk } from 'lodash';</script><script nomodule> runs only in browsers that do not support modules — a legacy fallback that is no longer needed for current browsers.
Why bundlers and transpilers exist
Section titled “Why bundlers and transpilers exist”Native modules work, so why does every project have a build step?
Bundlers (Vite, esbuild, Rollup, webpack, Parcel) exist for:
- Bare-specifier resolution. Turning
import 'react'into a real file path using Node resolution rules. - Fewer requests. A large app can have thousands of modules; even over HTTP/2 that is a lot of round trips on a cold cache.
- Tree-shaking. Removing unused exports, which requires the static ESM graph.
- Non-JS assets.
import styles from './app.css'andimport logo from './logo.svg'are not standard JavaScript. - Minification and code splitting. Smaller output, plus per-route chunks via dynamic
import(). - Content hashing.
app.a3f9c1.jsfilenames for long-lived caching. - Environment substitution. Replacing
process.env.NODE_ENVso dead branches can be dropped.
Transpilers (Babel, esbuild, swc, tsc) rewrite source into an older or different dialect:
- Modern syntax down to an older target for legacy browsers.
- TypeScript or JSX into plain JavaScript.
- ESM to CommonJS or the reverse, for publishing dual-format packages.
A minimal modern setup:
npm create vite@latest my-appcd my-appnpm installnpm run dev # dev server: native ESM, no bundling, instant reloadnpm run build # production: bundled, minified, hashed, tree-shakenVite’s model is the current mainstream: serve native ES modules straight to the browser in development (nothing to bundle, so startup is instant), and bundle with Rollup for production (where request count and tree-shaking still matter).
Key points
Section titled “Key points”- ES modules have their own scope, are always strict, and evaluate once per URL.
- Prefer named exports; keep one default per module for single-purpose files. Include the file extension in specifiers.
import/exportare static and hoisted — resolved before execution — which is what makes tree-shaking possible.- Imports are live, read-only bindings, unlike CommonJS’s value copies.
import()returns a promise and works anywhere; it is the code-splitting boundary.- Top-level
awaitworks in ES modules and delays every dependent module. - Node picks the format by extension (
.mjs/.cjs) or by"type"inpackage.json. ESM can import CJS as a default; CJS reaches ESM only through dynamicimport(). <script type="module">is deferred, scoped, strict, CORS-checked, and needs a server; bare specifiers need an import map.- Bundlers exist for resolution, request count, tree-shaking, assets, and minification; transpilers convert syntax, polyfills supply missing APIs.