Skip to content

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.

Every file is its own scope. Nothing is global unless you export it, and nothing is visible unless you import it.

math.js
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:

math.js
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 name

Importing:

app.js
import { PI, add } from './math.js';
import { add as sum } from './math.js'; // rename on import
import * as math from './math.js'; // namespace object
add(1, 2); // => 3
math.add(1, 2); // => 3
math.PI; // => 3.14159

One per module, imported without braces under any name you like.

logger.js
export default function log(msg) {
console.log(`[app] ${msg}`);
}
app.js
import log from './logger.js'; // name is yours to choose
import anyName from './logger.js'; // same function

You can combine both in one module and one import statement:

api.js
export default class ApiClient {}
export const VERSION = '1.0';
import ApiClient, { VERSION } from './api.js';

A “barrel” file collects a package’s public API:

index.js
export { add, PI } from './math.js';
export { default as Logger } from './logger.js';
export * from './utils.js'; // all named exports
export * as shapes from './shapes.js'; // as a namespace (ES2020)
import { add, Logger, shapes } from './index.js';

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 ran
import { 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.

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.

counter.js
export let count = 0;
export function increment() { count++; }
app.js
import { count, increment } from './counter.js';
console.log(count); // => 0
increment();
console.log(count); // => 1 ← the binding is live
count = 5; // TypeError: Assignment to constant variable

Only the owning module may change an exported binding. This differs sharply from CommonJS, where require copies the value at the moment of the call.

  • Top-level var/function do not become properties of the global object.
  • Modules are always strict mode.
  • Top-level this is undefined (in a CommonJS file it is module.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.
config.js
console.log('config evaluated'); // prints exactly once
export 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.

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 needed
button.addEventListener('click', async () => {
const { renderEditor } = await import('./editor.js');
renderEditor();
});
// 2. Conditional loading
const locale = navigator.language.startsWith('fr') ? 'fr' : 'en';
const messages = await import(`./locales/${locale}.js`);
// 3. Optional dependency
let 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.

In an ES module, await is allowed at the top level — no async wrapper needed.

db.js
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.

Node’s original system, still the default for any package without "type": "module" and for every .cjs file.

math.cjs
function add(a, b) { return a + b; }
const PI = 3.14159;
module.exports = { add, PI };
// or: exports.add = add;
app.cjs
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:

counter.cjs
let count = 0;
module.exports = { count, increment: () => count++ };
const { count, increment } = require('./counter.cjs');
increment();
console.log(count); // => 0 — a snapshot, not a live binding

Node decides per file:

  • .mjs → always ESM. .cjs → always CommonJS.
  • .js → ESM if the nearest package.json has "type": "module", otherwise CommonJS.
package.json
{
"name": "my-app",
"type": "module"
}

ESM can import CommonJS. Node treats module.exports as the default export.

import pkg from 'some-cjs-package'; // module.exports
const { 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:

app.cjs
(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.

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 defer is 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.
Terminal window
# Serve a folder for local module development
npx serve .
# or
python3 -m http.server 8000

Specifiers 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.

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' and import 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.js filenames for long-lived caching.
  • Environment substitution. Replacing process.env.NODE_ENV so 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:

Terminal window
npm create vite@latest my-app
cd my-app
npm install
npm run dev # dev server: native ESM, no bundling, instant reload
npm run build # production: bundled, minified, hashed, tree-shaken

Vite’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).

  • 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/export are 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 await works in ES modules and delays every dependent module.
  • Node picks the format by extension (.mjs/.cjs) or by "type" in package.json. ESM can import CJS as a default; CJS reaches ESM only through dynamic import().
  • <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.