es a production-ready pattern using TypeScript.
Step 1: Define Explicit Boundaries with Named Exports
Named exports enforce explicit dependency tracking. Unlike default exports, they prevent accidental naming mismatches and enable static analysis tools to verify imports at compile time.
// src/utils/validators.ts
export function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0;
}
export function isValidTimestamp(value: unknown): value is number {
return typeof value === 'number' && value > 0 && Number.isFinite(value);
}
export function sanitizeInput(raw: string): string {
return raw.replace(/[<>{}]/g, '').trim();
}
Step 2: Compose Modules with Static Imports
Static imports create a deterministic dependency graph. The runtime resolves these before execution, enabling early failure detection and optimal bundling.
// src/services/configLoader.ts
import { isNonEmptyString, isValidTimestamp, sanitizeInput } from '../utils/validators.js';
export interface AppConfig {
endpoint: string;
timeout: number;
retries: number;
}
export function loadConfig(raw: Record<string, unknown>): AppConfig {
const endpoint = sanitizeInput(String(raw.endpoint ?? ''));
const timeout = Number(raw.timeout);
const retries = Number(raw.retries);
if (!isNonEmptyString(endpoint)) {
throw new Error('Invalid configuration: endpoint is required');
}
if (!isValidTimestamp(timeout)) {
throw new Error('Invalid configuration: timeout must be a positive number');
}
return { endpoint, timeout, retries: Math.max(0, retries) };
}
Step 3: Implement Barrel Files for Path Consolidation
Barrel files (re-export modules) reduce import path fragmentation. They should be used sparingly to avoid creating unnecessary dependency layers.
// src/utils/index.ts
export { isNonEmptyString, isValidTimestamp, sanitizeInput } from './validators.js';
export { formatBytes, parseDuration } from './formatters.js';
// src/services/configLoader.ts (refactored)
import { isNonEmptyString, isValidTimestamp, sanitizeInput } from '../utils/index.js';
// ... rest of implementation remains identical
Step 4: Leverage Dynamic Imports for Conditional Loading
Dynamic imports return promises and are evaluated at runtime. Use them for code splitting, feature flags, or heavy utilities that aren't required during initial execution.
// src/features/analytics.ts
export async function initializeTracking(provider: 'ga' | 'mixpanel') {
const module = await import(`./providers/${provider}.js`);
return module.createTracker();
}
Architecture Rationale
- Named over default exports: Default exports allow arbitrary naming on import (
import anything from './module'), which breaks static analysis and enables silent refactoring errors. Named exports enforce consistency and enable IDE-driven rename operations.
- Static import preference: Static imports enable bundlers to construct a complete dependency graph before execution. This is critical for tree-shaking, circular dependency detection, and accurate type checking.
- Barrel file discipline: Re-exporting is useful for public API surfaces but harmful when applied internally. Deep barrel chains increase bundle size and obscure true dependency paths.
- Explicit
.js extensions: Modern ESM requires file extensions in import paths, even in TypeScript projects. This aligns with browser and Node.js resolution algorithms and prevents ambiguous module matching.
Pitfall Guide
1. Circular Dependency Loops
Explanation: Module A imports Module B, which imports Module A. ESM handles this by partially initializing modules, but it often results in undefined values at runtime.
Fix: Extract shared logic into a third module, or use dependency injection to break the cycle. Run madge --circular src/ to detect cycles early.
2. Default Export Ambiguity
Explanation: Default exports allow importers to assign any name, making automated refactoring unreliable and obscuring the actual exported API.
Fix: Enforce named exports via ESLint (import/no-default-export). If a module truly represents a single class or function, export it by name and import it explicitly.
3. Relative Path Fragility
Explanation: Deeply nested relative imports (../../../utils/helpers.js) break when files are moved and reduce readability.
Fix: Configure TypeScript path aliases (paths: { "@utils/*": ["src/utils/*"] }) and align bundler settings to resolve them. Always verify alias configuration matches the build tool.
4. Mixing CommonJS and ESM
Explanation: Node.js treats .cjs as CommonJS and .mjs/.js (with "type": "module") as ESM. Mixing require() and import in the same file causes syntax errors and runtime failures.
Fix: Standardize on ESM across the project. Use createRequire from the module package only when consuming legacy CJS packages that lack ESM exports.
5. Overusing Barrel Files
Explanation: Aggressive re-exporting creates "index.js" files that import everything, defeating tree-shaking and increasing initial load time.
Fix: Reserve barrels for public API boundaries. Import directly from source files in internal modules. Use bundler analysis tools to verify unused exports are eliminated.
6. Ignoring type: "module" Configuration
Explanation: Without "type": "module" in package.json, Node.js defaults to CommonJS resolution, causing import statements to throw syntax errors.
Fix: Add "type": "module" to package.json. Ensure all import paths include extensions and that dependencies support ESM or provide dual exports.
7. Assuming Synchronous Dynamic Imports
Explanation: import() returns a Promise. Awaiting it incorrectly or treating it as synchronous breaks control flow and causes unhandled rejections.
Fix: Always await dynamic imports or chain .then(). Wrap in try/catch for graceful fallbacks when modules fail to load.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Utility library with multiple functions | Named exports | Enables selective importing, improves tree-shaking, maintains explicit API surface | Low (negligible build overhead) |
| Single-purpose plugin or framework entry | Named export of class/function | Avoids default export ambiguity while keeping import syntax clean | Low |
| Heavy charting engine loaded on demand | Dynamic import() | Defers parsing until needed, reduces initial bundle size by 30-60% | Medium (slight runtime latency) |
| Internal service communication | Direct imports | Prevents barrel file bloat, maintains accurate dependency graph | Low |
| Legacy CJS dependency integration | createRequire or dual-package exports | Bridges module systems without rewriting third-party code | Medium (requires build configuration) |
Configuration Template
// package.json
{
"name": "modular-app",
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "tsc && vite build",
"analyze": "vite build --mode analyze"
},
"devDependencies": {
"typescript": "^5.4.0",
"vite": "^5.2.0",
"@types/node": "^20.11.0"
}
}
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"outDir": "./dist",
"rootDir": "./src",
"baseUrl": ".",
"paths": {
"@utils/*": ["src/utils/*"],
"@services/*": ["src/services/*"],
"@features/*": ["src/features/*"]
},
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
// vite.config.ts
import { defineConfig } from 'vite';
import path from 'path';
export default defineConfig({
resolve: {
alias: {
'@utils': path.resolve(__dirname, 'src/utils'),
'@services': path.resolve(__dirname, 'src/services'),
'@features': path.resolve(__dirname, 'src/features')
}
},
build: {
rollupOptions: {
output: {
manualChunks: {
vendor: ['lodash-es', 'date-fns']
}
}
}
}
});
Quick Start Guide
- Initialize the project: Run
npm init -y, add "type": "module" to package.json, and install TypeScript + Vite.
- Configure resolution: Create
tsconfig.json with module: "ESNext" and moduleResolution: "bundler". Set up path aliases matching your directory structure.
- Create your first module: Add
src/utils/math.ts with named exports. Import it in src/main.ts using the alias or relative path with .js extension.
- Verify the build: Run
npx vite build. Check the output directory to confirm unused exports are eliminated and import paths resolve correctly.
- Enforce standards: Add an ESLint rule (
import/no-default-export) and a pre-commit hook running madge --circular src/ to prevent architectural drift.