Tooling and bundlers
JavaScript admits ES Modules — the standardised module system since ES2015, supported natively in browsers and in Node.js. Each module is its own file with import and export declarations; modules are singletons (loaded once per origin) and execute strict by default. The principal forms: named exports (export const x = ...), default exports (export default ...), re-exports (export { x } from "./other.js"), dynamic imports (await import(...)). The conventional contemporary discipline: ES modules everywhere, with bundlers (Vite, esbuild, webpack) for substantial production. Older patterns (CommonJS in Node, AMD, IIFE in browsers) are legacy. Packages are managed via npm (or substantial alternatives — pnpm, yarn, bun); imports may resolve relative paths, package names, or import maps. The combination — ES modules as the principal mechanism, the substantial bundler ecosystem, npm as the substantial package registry — is the substance of JavaScript modularity.
Named exports
// math.js
export const PI = 3.14159;
export function add(a, b) {
return a + b;
}
export function subtract(a, b) {
return a - b;
}
export class Calculator {
// ...
}
// Or batch:
const PI = 3.14159;
function add(a, b) { return a + b; }
function subtract(a, b) { return a - b; }
export { PI, add, subtract };
// main.js
import { add, subtract, PI } from "./math.js";
console.log(add(2, 3));
console.log(PI);
Renaming imports
import { add as plus, subtract as minus } from "./math.js";
console.log(plus(2, 3));
Renaming exports
function _add(a, b) { return a + b; }
export { _add as add };
Importing all
import * as math from "./math.js";
console.log(math.add(2, 3));
console.log(math.PI);
Default exports
// user.js
export default class User {
constructor(name) { this.name = name; }
}
// Or:
const config = { host: "localhost", port: 8080 };
export default config;
// Or:
export default function (a, b) { return a + b; } // anonymous
// main.js
import User from "./user.js"; // any name OK
import config from "./config.js";
import sum from "./sum.js";
const u = new User("Alice");
The default export admits any name on import — substantial convention is to match the file name.
Mixed default + named
// api.js
export default function fetchData() { ... }
export const API_URL = "/api";
export class HttpError extends Error { ... }
// main.js
import fetchData, { API_URL, HttpError } from "./api.js";
Re-exports
// index.js (barrel file)
export { add, subtract } from "./math.js";
export { multiply, divide } from "./more-math.js";
export { default as User } from "./user.js";
// Re-export all named:
export * from "./math.js";
// Re-export with rename:
export { add as plus } from "./math.js";
// Re-export default as named:
export { default as User } from "./user.js";
The barrel pattern admits substantial single import path:
// Without barrel:
import { add } from "./utils/math.js";
import { User } from "./models/user.js";
import { fetchData } from "./api/data.js";
// With barrel:
import { add, User, fetchData } from "./utils/index.js";
(Note: barrels admit substantial bundle size if not tree-shaken.)
Dynamic imports
async function loadFeature() {
const { default: Feature } = await import("./feature.js");
new Feature().init();
}
// Conditional:
if (window.matchMedia("(min-width: 768px)").matches) {
const { setupDesktop } = await import("./desktop.js");
setupDesktop();
}
// On demand:
button.addEventListener("click", async () => {
const { showDialog } = await import("./dialog.js");
showDialog();
});
The import(...) admits runtime loading — substantial code-splitting and substantial conditional loading.
Dynamic import with default
const module = await import("./feature.js");
const Feature = module.default;
const { utility } = module;
// Or destructure inline:
const { default: Feature, utility } = await import("./feature.js");
Top-level await
ES modules admit await at the top level:
// config.js
const r = await fetch("/config.json");
export const config = await r.json();
// main.js
import { config } from "./config.js"; // waits for config to load
The pattern admits substantial async initialisation; treat as side-effecting.
import.meta
Module metadata:
console.log(import.meta.url); // "https://example.com/main.js"
// Resolve relative to module:
const url = new URL("./assets/icon.svg", import.meta.url);
// Modern (some runtimes):
const path = import.meta.resolve("./other.js");
HTML and modules
<script type="module" src="/main.js"></script>
<!-- Inline: -->
<script type="module">
import { setup } from "/setup.js";
setup();
</script>
<!-- Fallback for non-module browsers (rare in 2026): -->
<script nomodule src="/fallback.js"></script>
The principal facts about <script type="module">:
- Defer by default — runs after document parsed.
- Strict by default.
- Cross-origin requires CORS.
- Once per URL — second
<script>with same src does not re-execute.
Import maps
For bare specifiers in browsers:
<script type="importmap">
{
"imports": {
"lodash": "https://esm.sh/lodash@4",
"react": "https://esm.sh/react@18",
"./utils/": "/src/utils/"
}
}
</script>
<script type="module">
import _ from "lodash";
import React from "react";
import { helper } from "./utils/helper.js";
</script>
Import maps admit substantial bundler-free development for substantial small projects.
Package managers
The principal package manager: npm (Node Package Manager). Substantial alternatives: pnpm (efficient disk usage, strict), yarn (alternative), bun (fast).
# Initialise:
npm init -y
# Install:
npm install lodash
npm install --save-dev typescript
npm install -g eslint # global
# Update:
npm update
npm outdated
# Remove:
npm uninstall lodash
# Run scripts:
npm run dev
npm run build
npm test
# Audit:
npm audit
npm audit fix
package.json:
{
"name": "my-app",
"version": "1.0.0",
"type": "module",
"main": "src/index.js",
"scripts": {
"dev": "vite",
"build": "vite build",
"test": "vitest"
},
"dependencies": {
"lodash": "^4.17.21",
"react": "^18.0.0"
},
"devDependencies": {
"vite": "^5.0.0",
"typescript": "^5.0.0"
}
}
The "type": "module" admits ES modules in .js files — without it, .js is treated as CommonJS in Node.
CommonJS (legacy in Node)
// math.js (CommonJS)
function add(a, b) { return a + b; }
module.exports = { add };
// Or:
module.exports.add = add;
exports.add = add;
// main.js (CommonJS)
const { add } = require("./math.js");
const math = require("./math.js");
In a Node project with "type": "module", .cjs files admit CommonJS; .mjs admits ES modules; .js follows type.
For interop:
// In ES modules, importing CommonJS:
import lodash from "lodash"; // gets module.exports
// In CommonJS, importing ES modules:
const { add } = await import("./math.js"); // dynamic only
Bundlers
In production, the conventional contemporary discipline uses a bundler:
| Bundler | Notes |
|---|---|
| Vite | Modern; ESM-first dev server, Rollup for build. The principal contemporary choice for new apps. |
| esbuild | Substantial fast; underpins many tools. |
| Rollup | Library-focused; substantial tree-shaking. |
| webpack | Mature; substantial plugin ecosystem. |
| Parcel | Zero-config. |
| bun | All-in-one runtime + bundler. |
| Turbopack | Next.js’s bundler. |
| Rspack | Rust-based webpack-compatible. |
A typical Vite project:
npm create vite@latest my-app -- --template vanilla
cd my-app
npm install
npm run dev
vite.config.js:
import { defineConfig } from "vite";
export default defineConfig({
server: { port: 3000 },
build: {
outDir: "dist",
rollupOptions: {
input: {
main: "index.html",
admin: "admin.html"
}
}
}
});
Tree-shaking
Bundlers eliminate unused exports — admit substantial bundle size:
// utils.js
export function used() { /* ... */ }
export function unused() { /* ... */ }
// main.js
import { used } from "./utils.js";
used();
// `unused` is dropped from the bundle (if no side effects)
For substantial tree-shaking:
- ES modules — required (CommonJS does not admit static analysis).
- No side-effects in module top level — or marked in
package.json:{ "sideEffects": false }
Code splitting
// Static: each `<script type="module">` is one chunk.
// Dynamic: `await import(...)` admits a separate chunk.
const { default: Heavy } = await import("./heavy.js");
Bundlers split:
- Per route (in frameworks).
- Per dynamic import.
- Vendor chunks (bundled separately).
Conditional exports
package.json exports:
{
"name": "my-lib",
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
},
"./utils": "./dist/utils.mjs",
"./styles.css": "./dist/styles.css",
"./package.json": "./package.json"
}
}
import lib from "my-lib"; // resolves "."
import utils from "my-lib/utils"; // resolves "./utils"
import "my-lib/styles.css";
The mechanism admits substantial control over what consumers see.
TypeScript and modules
// In tsconfig.json:
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler", // or "Node16"
"esModuleInterop": true
}
}
// math.ts:
export function add(a: number, b: number): number {
return a + b;
}
export type Result = { ok: true; value: number } | { ok: false; error: string };
// main.ts:
import { add, type Result } from "./math.js"; // .js even though source is .ts
// `import type` (no runtime import):
import type { Result } from "./math.js";
Common patterns
Barrel file
// components/index.js
export { default as Button } from "./Button.js";
export { default as Input } from "./Input.js";
export { default as Card } from "./Card.js";
// usage:
import { Button, Input, Card } from "./components/index.js";
Side-effect import
import "./styles.css"; // CSS injected
import "./polyfill.js"; // runs immediately
Lazy route
const routes = {
"/": () => import("./pages/Home.js"),
"/about": () => import("./pages/About.js"),
"/admin": () => import("./pages/Admin.js")
};
async function navigate(path) {
const loader = routes[path];
const { default: Page } = await loader();
render(Page);
}
Worker import
const worker = new Worker(new URL("./worker.js", import.meta.url), { type: "module" });
The new URL("./worker.js", import.meta.url) form admits substantial bundler-friendly worker references.
Asset import
With Vite/webpack, asset imports return URLs:
import imageUrl from "./image.png";
import iconUrl from "./icon.svg?url";
import iconHtml from "./icon.svg?raw";
document.querySelector("img").src = imageUrl;
Re-export with rename
// public-api.js
export { _internalThing as PublicThing } from "./internal.js";
Module factory
// db.js
let _db;
export async function getDb() {
if (!_db) {
const { openDB } = await import("idb");
_db = await openDB("app", 1, { upgrade(db) { /* ... */ } });
}
return _db;
}
Module-level singleton
// auth.js
class AuthService {
constructor() { ... }
login(creds) { ... }
}
export const auth = new AuthService(); // singleton
Plugin pattern
// app.js
const plugins = [];
export function use(plugin) {
plugins.push(plugin);
plugin.install?.();
}
export async function start() {
for (const p of plugins) {
await p.start?.();
}
}
// elsewhere:
import { use, start } from "./app.js";
import logger from "./plugins/logger.js";
import telemetry from "./plugins/telemetry.js";
use(logger);
use(telemetry);
await start();
Configuration loading
// config.js
const r = await fetch("/config.json");
const config = await r.json();
export default config;
// main.js
import config from "./config.js"; // top-level await ensures loaded
Conditional import
let analytics;
if (import.meta.env.PROD) {
analytics = await import("./prod-analytics.js");
} else {
analytics = await import("./dev-analytics.js");
}
Re-export and freeze
// constants.js
export const STATUS = Object.freeze({
ACTIVE: "active",
INACTIVE: "inactive"
});
// elsewhere:
import { STATUS } from "./constants.js";
console.log(STATUS.ACTIVE);
A note on monorepos
For substantial projects:
| Tool | Notes |
|---|---|
| npm workspaces | Built-in; substantial. |
| pnpm workspaces | Substantial efficient; substantial discipline. |
| Turborepo | Substantial caching, parallel builds. |
| Nx | Substantial enterprise; substantial tooling. |
package.json:
{
"workspaces": ["packages/*"]
}
npm install # installs all
npm run build --workspaces # all packages
npm run test -w pkg-a # one package
A note on registries
Public packages are typically on npm registry (registry.npmjs.org). Substantial alternatives:
- jsr.io — modern; URL-based imports; first-class TS.
- deno.land/x — Deno’s substantial registry.
- GitHub Packages — for private/scoped packages.
- Verdaccio — self-hosted private registry.
A note on the conventional discipline
The contemporary modules advice:
- Use ES modules —
"type": "module"and.js/.mjs/.ts. - Use named exports over defaults — substantial more discoverable.
- Use a bundler (Vite is the conventional contemporary choice) for substantial production.
- Use TypeScript for substantial type-safe modules.
- Use barrels carefully — admit substantial bundle bloat if not tree-shaken.
- Use dynamic imports for substantial code splitting.
- Use top-level await sparingly — substantial blocks loading.
- Use
import.meta.urlfor substantial asset/worker references. - Pin versions with lockfiles (
package-lock.json,pnpm-lock.yaml). - Audit dependencies —
npm audit, Snyk, Socket. - Use
exportsinpackage.jsonfor substantial public API control. - Use a monorepo tool (npm workspaces, pnpm, Turborepo) for substantial multi-package.
The combination — ES modules as the principal mechanism, named/default/re-exports, dynamic imports for substantial splitting, the bundler ecosystem (Vite, esbuild, etc.), the npm ecosystem with substantial alternatives, the substantial monorepo tooling — is the substance of JavaScript modularity. The discipline produces well-organised, tree-shakeable, deployable applications.