h function remains private until explicitly exposed.
// validators.ts
export function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0;
}
export function isPositiveInteger(value: unknown): value is number {
return typeof value === 'number' && Number.isInteger(value) && value > 0;
}
export function sanitizeInput(raw: string): string {
return raw.replace(/[<>]/g, '').trim();
}
Rationale: Named exports create a stable contract. Bundlers can statically analyze which exports are actually consumed and eliminate the rest. IDEs resolve named imports with higher accuracy, reducing refactoring friction.
Step 2: Establish Primary Entry Points with Default Exports
When a file represents a single cohesive unit (a class, a service, or a configuration singleton), a default export signals primary responsibility. This should be used sparingly and only when the file's purpose is singular.
// streamEngine.ts
import { isNonEmptyString, sanitizeInput } from './validators.js';
export default class DataStreamEngine {
private buffer: string[] = [];
push(rawData: string): void {
const cleaned = sanitizeInput(rawData);
if (isNonEmptyString(cleaned)) {
this.buffer.push(cleaned);
}
}
flush(): string[] {
const snapshot = [...this.buffer];
this.buffer = [];
return snapshot;
}
}
Rationale: Default exports communicate intent: this file exists to provide one primary abstraction. The internal helpers (isNonEmptyString, sanitizeInput) remain encapsulated. The class interface is the only public surface.
Step 3: Wire Dependencies with Explicit Imports
Static imports must match the export type exactly. Named imports require curly braces and exact identifiers. Default imports omit braces and allow local renaming. Namespace imports (* as) should be reserved for debugging or dynamic feature loading, as they disable tree-shaking.
// app.ts
import DataStreamEngine from './streamEngine.js';
import { isPositiveInteger } from './validators.js';
const processor = new DataStreamEngine();
const payload = [
' <script>alert(1)</script> ',
'42',
' ',
'valid-record-01'
];
payload.forEach(item => {
if (isPositiveInteger(Number(item))) {
processor.push(`ID:${item}`);
} else {
processor.push(item);
}
});
console.log(processor.flush());
// Output: ['ID:42', 'valid-record-01']
Rationale: Explicit imports create a visible dependency graph. The build system can trace app.ts β streamEngine.ts β validators.ts and verify that no unused exports remain in the final bundle.
In browser environments, ESM requires explicit opt-in. The type="module" attribute triggers strict mode, defers execution until DOM parsing completes, and enforces file-level scoping. Crucially, ESM cannot execute from the file:// protocol due to CORS restrictions; a local development server is mandatory.
<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Module Boundary Demo</title>
</head>
<body>
<script type="module" src="./app.js"></script>
</body>
</html>
Rationale: type="module" transforms the execution context. Variables no longer leak to window. Scripts load asynchronously and execute in dependency order. This eliminates the historical <script> tag ordering nightmare while providing a standardized, spec-compliant module loader.
Pitfall Guide
1. Circular Dependency Deadlocks
Explanation: Module A imports Module B, and Module B imports Module A. The static resolver cannot determine initialization order, resulting in undefined exports or runtime ReferenceError.
Fix: Extract shared interfaces or constants into a third module (shared.ts). Alternatively, use dynamic import() for lazy resolution, or apply dependency inversion by passing dependencies as constructor arguments rather than importing them directly.
2. Default Export Proliferation
Explanation: Using export default for utility files, configuration objects, or multi-function modules. This breaks tree-shaking, obscures the public API, and forces consumers to guess the export name.
Fix: Switch to named exports. If a default export is required for legacy compatibility, re-export explicitly: export { default } from './module.js'. Reserve default exports for single-responsibility classes or components.
3. Missing File Extensions in Browser ESM
Explanation: The ES Module specification requires explicit file extensions in import paths. Omitting .js or .ts causes TypeError: Failed to resolve module specifier in native browser environments.
Fix: Always include the extension in import statements. If using a bundler, configure path aliases to maintain consistency, but never rely on implicit resolution in production ESM code.
4. Namespace Import Bloat
Explanation: import * as Utils from './utils.js' pulls the entire module namespace into memory. This prevents static tree-shaking, increases bundle size, and obscures which specific functions are actually used.
Fix: Use named imports for production code. Reserve namespace imports for development tooling, debugging, or when dynamically iterating over module exports is explicitly required.
5. Implicit Global State via Side-Effect Imports
Explanation: import './setup.js' executes the module without binding any exports. If setup.js mutates window or attaches event listeners, it creates hidden dependencies that are invisible to static analysis.
Fix: Replace side-effect imports with explicit initialization functions. Export a configure() or initialize() function and call it deliberately. This makes side effects traceable and testable.
6. Ignoring Strict Mode Implications
Explanation: ES Modules run in strict mode automatically. Top-level this is undefined, not window. Functions without explicit binding will throw TypeError when accessing this.
Fix: Embrace strict mode. Use arrow functions for lexical this binding, or explicitly bind context in constructors. Never rely on implicit global this behavior; it is a legacy anti-pattern that ESM intentionally removes.
7. Bundler vs Runtime Mismatch
Explanation: Node.js historically used CommonJS (require/module.exports). Mixing CJS and ESM causes ERR_REQUIRE_ESM or ERR_MODULE_NOT_FOUND. Node-specific globals like __dirname and __filename are unavailable in ESM.
Fix: Set "type": "module" in package.json. Replace __dirname with import.meta.url and new URL('.', import.meta.url).pathname. Configure bundlers to resolve ESM-first, and avoid mixing module systems in the same dependency tree.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Utility library (formatters, validators, math) | Named exports | Enables precise tree-shaking, improves IDE autocomplete, maintains explicit API | Low (negligible build overhead) |
| UI component or service class | Default export | Signals single responsibility, simplifies consumer imports, aligns with framework conventions | Low (minor bundle size increase if overused) |
| Configuration singleton | Named export + frozen object | Prevents accidental mutation, allows selective import, maintains testability | Low (zero runtime cost) |
| Dynamic feature loading | import() with named exports | Enables code-splitting, reduces initial payload, preserves tree-shaking on resolved chunks | Medium (requires routing/lazy-loading infrastructure) |
| Legacy CJS migration | export { default } + "type": "module" | Bridges compatibility gaps, enables gradual migration, avoids rewrite debt | Medium (temporary build config complexity) |
Configuration Template
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "./dist",
"declaration": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
// vite.config.ts
import { defineConfig } from 'vite';
import { resolve } from 'path';
export default defineConfig({
build: {
rollupOptions: {
input: {
main: resolve(__dirname, 'src/app.ts')
},
output: {
manualChunks: {
validators: ['./src/validators.ts']
}
}
},
target: 'es2022',
minify: 'esbuild'
},
server: {
open: true,
strictPort: true
}
});
Quick Start Guide
- Initialize project structure: Create
src/ directory with validators.ts, streamEngine.ts, and app.ts. Add index.html with <script type="module" src="./src/app.ts"></script>.
- Configure tooling: Run
npm init -y, install vite and typescript, and apply the tsconfig.json and vite.config.ts templates above.
- Write first module boundary: Implement named exports in
validators.ts, default export in streamEngine.ts, and wire them in app.ts using explicit imports.
- Launch development server: Execute
npx vite. The server automatically handles ESM resolution, strict mode enforcement, and hot module replacement. Verify the console output matches expected results.
- Validate production build: Run
npx vite build. Inspect the dist/ output to confirm unused exports are eliminated and bundle size reflects static analysis optimizations.