licit Export Contracts
Instead of dumping everything into a single namespace, separate concerns into focused modules. Use named exports for utilities and constants, and reserve default exports for single-responsibility classes or primary entry points.
// pricing-engine.ts
export interface PricingConfig {
currency: string;
taxRate: number;
discountThreshold: number;
}
export const DEFAULT_CONFIG: PricingConfig = {
currency: 'USD',
taxRate: 0.08,
discountThreshold: 100,
};
export function calculateFinalPrice(
base: number,
config: PricingConfig = DEFAULT_CONFIG
): number {
const tax = base * config.taxRate;
const discount = base >= config.discountThreshold ? base * 0.1 : 0;
return base + tax - discount;
}
export function formatCurrency(amount: number, currency: string): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
}).format(amount);
}
Step 2: Import with Precision
Consumers should only request the exact symbols they need. This enables static analysis tools to prune unused code and prevents accidental namespace pollution.
// inventory-tracker.ts
import { calculateFinalPrice, formatCurrency } from './pricing-engine.js';
export function generateInvoiceLine(
itemName: string,
unitPrice: number,
quantity: number
): string {
const total = calculateFinalPrice(unitPrice * quantity);
const formatted = formatCurrency(total, 'USD');
return `${itemName} x${quantity} β ${formatted}`;
}
Step 3: Handle Mixed Export Patterns
When a module provides a primary class alongside helper functions, combine default and named exports. The default export represents the module's main responsibility, while named exports provide supplementary utilities.
// event-bus.ts
type Listener = (payload: unknown) => void;
export default class EventBus {
private subscribers: Map<string, Set<Listener>> = new Map();
subscribe(event: string, listener: Listener): void {
if (!this.subscribers.has(event)) {
this.subscribers.set(event, new Set());
}
this.subscribers.get(event)!.add(listener);
}
emit(event: string, payload: unknown): void {
this.subscribers.get(event)?.forEach((fn) => fn(payload));
}
}
export const createSingleton = () => new EventBus();
export const EVENT_TYPES = {
CART_UPDATE: 'cart:update',
USER_LOGIN: 'user:login',
};
Step 4: Consume Mixed Exports
Import the default class and named utilities in a single statement. The default import appears first without braces, followed by named imports in curly braces.
// app-entry.ts
import EventBus, { createSingleton, EVENT_TYPES } from './event-bus.js';
const bus = createSingleton();
bus.subscribe(EVENT_TYPES.CART_UPDATE, (payload) => {
console.log('Cart state changed:', payload);
});
bus.emit(EVENT_TYPES.CART_UPDATE, { items: 3, total: 49.99 });
Step 5: Leverage Aliasing and Namespace Imports
When naming conflicts arise or when consuming an entire module as a single object, use as for aliasing or * as for namespace aggregation.
// legacy-migration.ts
import { calculateFinalPrice as computeTotal } from './pricing-engine.js';
import * as PricingUtils from './pricing-engine.js';
console.log(computeTotal(200)); // Uses alias
console.log(PricingUtils.formatCurrency(50, 'EUR')); // Namespace access
Architecture Decisions & Rationale
- Named exports over default for utilities: Named exports enforce explicit contracts. Bundlers can statically analyze and remove unused named exports during tree-shaking. Default exports obscure what a module actually provides, making refactoring harder and tree-shaking less reliable.
- Explicit
.js extensions in imports: Modern ESM requires explicit file extensions in Node.js and strict bundler configurations. This eliminates ambiguous path resolution and ensures consistent behavior across environments.
- Static resolution over dynamic:
import statements are hoisted and resolved at parse time. This guarantees that all dependencies are available before execution, preventing runtime ReferenceError crashes and enabling ahead-of-time optimization.
- File-level scope isolation: Each module runs in its own lexical environment. Variables declared with
let, const, or function never leak to the global object, eliminating naming collisions without requiring IIFEs or closure wrappers.
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 errors.
Fix: Break the cycle by extracting shared logic into a third module (C) that both A and B import. Alternatively, use dynamic import() for lazy evaluation when the dependency is only needed conditionally.
2. Overusing Default Exports
Explanation: Default exports allow consumers to rename the import arbitrarily, which breaks static analysis and makes refactoring unpredictable. They also hinder tree-shaking in some bundler configurations.
Fix: Prefer named exports for all utilities, constants, and functions. Reserve default exports strictly for single-responsibility classes or framework components where the consumer expects a single primary export.
3. Namespace Import Bloat
Explanation: import * as Utils from './module.js' pulls every export into memory, even if only one is used. This defeats tree-shaking and increases initial bundle size.
Fix: Use explicit named imports (import { specificFn } from './module.js'). Only use namespace imports when you genuinely need dynamic property access or are migrating legacy code.
4. Implicit Global Assumptions
Explanation: Developers accustomed to script tags assume this refers to the global object or that variables declared without let/const are safe. ESM runs in strict mode automatically, and this is undefined at the top level.
Fix: Always declare variables explicitly. Never rely on implicit globals. Use globalThis or environment-specific globals (window, global) only when absolutely necessary, and type them correctly in TypeScript.
5. Path Resolution Ambiguity
Explanation: Omitting file extensions or using relative paths without explicit .js/.ts suffixes causes inconsistent behavior across Node.js, bundlers, and TypeScript compilers.
Fix: Always include the .js extension in import paths, even when writing TypeScript. Configure moduleResolution: 'node16' or 'bundler' in tsconfig.json to align compiler expectations with runtime behavior.
6. Barrel File Overuse
Explanation: Re-exporting everything from an index.ts barrel file (export * from './module.js') creates a single large chunk that defeats code splitting and increases initial load time.
Fix: Use barrel files only for public API surfaces of libraries. For internal application modules, import directly from the source file to preserve chunk boundaries and enable granular lazy loading.
7. Mixing CJS and ESM Incorrectly
Explanation: Attempting to require() an ES module or import a CommonJS module without proper configuration causes syntax errors or undefined exports.
Fix: Standardize on ESM across the project. If legacy CJS dependencies exist, use dynamic import() or configure bundler aliases. In Node.js, set "type": "module" in package.json and use import.meta.url for path resolution instead of __dirname.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Utility library (formatters, validators) | Named exports only | Enables precise tree-shaking, explicit contracts, easier refactoring | Low (better bundle size) |
| Framework component (React, Vue) | Default export | Aligns with framework conventions, cleaner consumer syntax | Neutral |
| Configuration singleton | Default export + named constants | Single source of truth, prevents accidental re-instantiation | Low |
| Dynamic feature loading | import() with named exports | Enables code splitting, reduces initial payload, lazy evaluation | Medium (build complexity) |
| Legacy CJS dependency | Dynamic import() or bundler alias | Avoids syntax conflicts, maintains ESM consistency | Low |
Configuration Template
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
// vite.config.ts
import { defineConfig } from 'vite';
import path from 'path';
export default defineConfig({
resolve: {
alias: {
'@core': path.resolve(__dirname, 'src/core'),
'@utils': path.resolve(__dirname, 'src/utils'),
'@types': path.resolve(__dirname, 'src/types'),
},
},
build: {
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom'],
utils: ['@utils/pricing', '@utils/formatting'],
},
},
},
},
});
Quick Start Guide
- Initialize the project: Run
npm init -y and install TypeScript + Vite: npm i -D typescript vite @types/node.
- Configure TypeScript: Create
tsconfig.json with the template above. Set "module": "ESNext" and "moduleResolution": "bundler".
- Create module boundaries: Inside
src/, create pricing-engine.ts, event-bus.ts, and app-entry.ts using the code examples from the Core Solution.
- Run the development server: Execute
npx vite. Vite will resolve imports statically, serve modules via native ESM, and enable hot module replacement without bundling.
- Verify production build: Run
npx vite build. Check the dist/ output to confirm tree-shaking removed unused exports and chunk boundaries align with your manual configuration.