nterface ExchangeRate {
from: CurrencyCode;
to: CurrencyCode;
rate: number;
}
// Internal state: never exposed
const rateCache: Map<string, ExchangeRate> = new Map();
function generateCacheKey(from: CurrencyCode, to: CurrencyCode): string {
return ${from}:${to};
}
export function fetchExchangeRate(from: CurrencyCode, to: CurrencyCode): number {
const key = generateCacheKey(from, to);
if (rateCache.has(key)) {
return rateCache.get(key)!.rate;
}
// Simulated API call
const mockRate = from === 'USD' && to === 'EUR' ? 0.92 : 1.0;
rateCache.set(key, { from, to, rate: mockRate });
return mockRate;
}
export function convertAmount(amount: number, from: CurrencyCode, to: CurrencyCode): number {
if (amount < 0) throw new RangeError('Amount must be non-negative');
const rate = fetchExchangeRate(from, to);
return Number((amount * rate).toFixed(2));
}
**Architecture Rationale:**
- `rateCache` and `generateCacheKey` remain lexical to the module. External code cannot mutate or access them.
- `fetchExchangeRate` and `convertAmount` are explicitly exported. Consumers must import them by name.
- This pattern guarantees that bundlers can statically determine which exports are actually used, enabling aggressive tree-shaking.
### Step 2: Compose Modules with Explicit Imports
Import statements create live bindings to the original exports. They do not copy values; they create references that update if the source module mutates its exported state.
```typescript
// src/modules/transaction-processor.ts
import { convertAmount, fetchExchangeRate } from './currency-converter.js';
import type { CurrencyCode } from './currency-converter.js';
interface Transaction {
id: string;
amount: number;
sourceCurrency: CurrencyCode;
targetCurrency: CurrencyCode;
}
export function processTransaction(tx: Transaction): Transaction {
const convertedAmount = convertAmount(tx.amount, tx.sourceCurrency, tx.targetCurrency);
return {
...tx,
amount: convertedAmount,
id: crypto.randomUUID()
};
}
export function validateCurrencyPair(from: CurrencyCode, to: CurrencyCode): boolean {
const rate = fetchExchangeRate(from, to);
return rate > 0;
}
Why this structure works:
- Imports are static and hoisted. The module graph is resolved before execution begins.
- Type imports (
import type) are stripped during compilation, reducing runtime overhead.
- The
.js extension is required in browser environments and modern Node.js ESM mode. Omitting it breaks native module resolution.
Step 3: Leverage Default Exports for Single-Responsibility Entry Points
Default exports are appropriate when a module represents a single cohesive unit, such as a class or a configuration singleton.
// src/modules/audit-logger.ts
interface LogEntry {
timestamp: Date;
level: 'info' | 'warn' | 'error';
message: string;
context?: Record<string, unknown>;
}
class AuditLogger {
private logs: LogEntry[] = [];
private readonly prefix: string;
constructor(prefix: string) {
this.prefix = prefix;
}
record(level: LogEntry['level'], message: string, context?: Record<string, unknown>): void {
const entry: LogEntry = {
timestamp: new Date(),
level,
message: `${this.prefix} :: ${message}`,
context
};
this.logs.push(entry);
console[level === 'error' ? 'error' : 'log'](entry.message);
}
getHistory(): ReadonlyArray<LogEntry> {
return Object.freeze([...this.logs]);
}
}
export default AuditLogger;
Importing the default:
// src/main.ts
import AuditLogger from './modules/audit-logger.js';
import { processTransaction } from './modules/transaction-processor.js';
const logger = new AuditLogger('PAYMENT-SVC');
logger.record('info', 'Service initialized');
const tx = processTransaction({
id: 'tx-001',
amount: 150.00,
sourceCurrency: 'USD',
targetCurrency: 'EUR'
});
logger.record('info', 'Transaction processed', { txId: tx.id, finalAmount: tx.amount });
Architecture Decision: Default exports reduce import verbosity for primary classes but sacrifice explicitness. Use them only when the module's sole purpose is to expose that single entity. Pair them with named exports for utilities or constants to maintain clarity.
Pitfall Guide
1. Circular Dependency Loops
Explanation: Module A imports Module B, which imports Module A. The static resolver cannot determine execution order, resulting in undefined bindings or runtime ReferenceError.
Fix: Extract shared logic into a third module (C) that both A and B import. Alternatively, use dynamic import() for lazy resolution, though this sacrifices static analysis benefits.
2. Default Export Ambiguity
Explanation: Importers assign arbitrary names to default exports (import Logger from './logger.js' vs import AppLogger from './logger.js'). This breaks searchability and makes refactoring dangerous.
Fix: Reserve default exports for framework entry points or single-class modules. Prefer named exports for utilities, hooks, and shared logic. Enforce naming consistency via ESLint rules (import/default, import/no-default-export).
3. Browser Path Resolution Failures
Explanation: Native ESM in browsers requires explicit file extensions and relative/absolute paths. Omitting .js or using bare specifiers (import { x } from 'utils') throws TypeError: Failed to resolve module specifier.
Fix: Always include extensions in browser-targeted code. Use bundlers (Vite, Rollup, Webpack) or import maps to resolve bare specifiers in production. Configure package.json "type": "module" and "exports" fields for Node.js compatibility.
4. Misunderstanding Live Bindings
Explanation: Imported values are live connections to the original module's exports. Mutating an imported object or array affects the source module. Developers often assume imports are copied by value.
Fix: Treat imported state as immutable. If mutation is required, export factory functions or immutable data structures. Use Object.freeze() or readonly TypeScript types to enforce immutability at the boundary.
5. Namespace Import (import * as) Overuse
Explanation: import * as Utils from './utils.js' creates a namespace object containing all exports. While convenient, it defeats tree-shaking because bundlers cannot statically determine which properties are actually accessed.
Fix: Use named imports for explicit dependencies. Reserve namespace imports for plugin systems, dynamic routing, or when consuming entire utility libraries where tree-shaking is already handled by the package author.
6. Ignoring Static Analysis Limits
Explanation: ESM imports are static. You cannot conditionally import modules or use variables in import specifiers (import { x } from dynamicPath). This breaks dynamic loading patterns.
Fix: Use import() for runtime-dependent loading (code splitting, feature flags, lazy routes). Keep static imports for core dependencies that must be available at startup.
7. Mixing CommonJS and ESM Incorrectly
Explanation: Node.js treats .cjs as CommonJS and .mjs/.js (with "type": "module") as ESM. Mixing require() and import in the same file throws syntax errors. Cross-format imports require careful interop handling.
Fix: Standardize on ESM across the codebase. If legacy CJS packages are unavoidable, use dynamic import() or configure bundler aliases. Avoid __dirname/__filename in ESM; use import.meta.url with pathToFileURL instead.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Utility library with multiple functions | Named exports | Enables tree-shaking, explicit dependencies, better IDE support | Low (minimal refactoring) |
| Framework component or singleton class | Default export | Reduces import verbosity, aligns with framework conventions | Low |
| Cross-package shared types | import type | Stripped at compile time, zero runtime overhead | None |
| Feature-gated or lazy-loaded module | Dynamic import() | Defers loading until runtime condition is met | Medium (requires code splitting config) |
| Legacy CommonJS dependency | Dynamic import() or bundler alias | Avoids syntax conflicts, maintains ESM standard | Low-Medium |
Configuration Template
package.json
{
"name": "modular-payment-system",
"version": "1.0.0",
"type": "module",
"exports": {
".": "./src/index.ts",
"./currency": "./src/modules/currency-converter.ts",
"./audit": "./src/modules/audit-logger.ts"
},
"scripts": {
"build": "tsc && vite build",
"lint": "eslint src --ext .ts,.js"
}
}
tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"declaration": true,
"outDir": "./dist",
"rootDir": "./src",
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
vite.config.ts
import { defineConfig } from 'vite';
import { resolve } from 'path';
export default defineConfig({
build: {
lib: {
entry: resolve(__dirname, 'src/index.ts'),
formats: ['es'],
fileName: 'payment-core'
},
rollupOptions: {
external: ['crypto'],
output: {
preserveModules: true,
preserveModulesRoot: 'src'
}
},
minify: 'esbuild',
sourcemap: true
}
});
Quick Start Guide
- Initialize ESM Project: Run
npm init -y, add "type": "module" to package.json, and install TypeScript (npm i -D typescript).
- Create Module Structure: Set up
src/modules/ with explicit boundaries. Write one utility module using named exports and one class module using a default export.
- Configure Tooling: Add
tsconfig.json with "module": "ESNext" and "moduleResolution": "bundler". Install Vite (npm i -D vite) and apply the configuration template above.
- Verify Resolution: Run
npx vite build. Check the output directory to confirm modules are preserved, tree-shaking is active, and no circular dependencies exist.
- Enforce Standards: Add
eslint-plugin-import and @typescript-eslint/parser to your linting pipeline. Configure rules to prevent default exports in utility files and require explicit import paths.
Mastering ES modules is not about memorizing syntax; it is about designing systems where boundaries are explicit, dependencies are static, and execution is predictable. When applied consistently, the module system transforms JavaScript from a fragile scripting language into a disciplined engineering platform.