s
Modern packages should explicitly declare their public surface area. The exports field replaces main and module as the authoritative resolution map, while imports creates internal aliases that cannot be accessed by consumers.
{
"name": "@acme/data-pipeline",
"version": "2.1.0",
"type": "module",
"exports": {
".": {
"import": "./dist/core/index.js",
"types": "./dist/core/index.d.ts"
},
"./transformers": {
"import": "./dist/transformers/index.js",
"types": "./dist/transformers/index.d.ts"
},
"./package.json": "./package.json"
},
"imports": {
"#internal/logger": "./src/utils/logger.js",
"#internal/config": "./src/config/runtime.js"
},
"types": "./dist/core/index.d.ts"
}
Why this structure: The exports map enforces explicit public APIs. Consumers cannot accidentally import internal files like ./src/utils/logger.js. The imports map with the # prefix creates private aliases scoped to the package, improving internal refactoring safety without breaking external contracts.
Step 2: Module Definition & Export Strategies
ESM supports multiple export patterns. Choosing the right pattern depends on the module's role in the system.
// src/core/pipeline.ts
import { createLogger } from '#internal/logger';
import type { PipelineOptions } from './types.js';
const logger = createLogger('pipeline');
export class DataPipeline {
private config: PipelineOptions;
constructor(options: PipelineOptions) {
this.config = Object.freeze(options);
logger.info('Pipeline initialized');
}
async execute(source: string): Promise<void> {
logger.debug(`Processing: ${source}`);
// Core execution logic
}
}
export const DEFAULT_TIMEOUT = 5000;
export type { PipelineOptions };
// src/transformers/normalizer.ts
import type { RawRecord } from '../core/types.js';
export function normalizeRecord(record: RawRecord): Record<string, unknown> {
const { id, timestamp, payload } = record;
return {
identifier: String(id).padStart(8, '0'),
processedAt: new Date(timestamp).toISOString(),
data: payload
};
}
export function batchNormalize(records: RawRecord[]): Record<string, unknown>[] {
return records.map(normalizeRecord);
}
Why named exports over default: Named exports enable precise tree-shaking and prevent accidental namespace collisions. Default exports encourage monolithic files and complicate refactoring. Use default exports only for framework entry points or single-responsibility components where the module's sole purpose is to expose one primary artifact.
Step 3: Dynamic Resolution & Feature Gating
Static imports resolve at parse time. Dynamic imports defer resolution until runtime, enabling lazy loading, conditional execution, and route-based code splitting.
// src/features/analytics.ts
import type { AnalyticsAdapter } from './types.js';
export async function loadAnalyticsProvider(env: string): Promise<AnalyticsAdapter> {
if (env === 'production') {
const { ProductionAdapter } = await import('./providers/production.js');
return new ProductionAdapter();
}
const { MockAdapter } = await import('./providers/mock.js');
return new MockAdapter();
}
Why dynamic imports: They break the synchronous dependency chain, allowing the runtime to fetch modules only when required. This reduces initial bundle size and enables environment-specific feature loading without conditional bundler plugins.
Step 4: Barrel Files & Re-exports
Barrel files consolidate public APIs but require careful handling to avoid tree-shaking degradation.
// src/index.ts
export { DataPipeline, DEFAULT_TIMEOUT } from './core/pipeline.js';
export { normalizeRecord, batchNormalize } from './transformers/normalizer.js';
export type { PipelineOptions, RawRecord } from './core/types.js';
Why explicit re-exports: Re-exporting named symbols preserves static analysis. Avoid export * from './module.js' in production barrels, as some bundlers cannot statically determine which symbols are actually used, leading to incomplete tree-shaking.
Pitfall Guide
1. Assuming Named Exports Work Seamlessly from CJS Packages
Explanation: CJS uses module.exports = { ... }, which ESM interprets as a single default export. Named imports like import { foo } from 'cjs-pkg' often fail or return undefined because the CJS module doesn't expose static named bindings.
Fix: Import the entire CJS module as a default, then destructure: import cjsPkg from 'cjs-pkg'; const { foo } = cjsPkg; Alternatively, use dynamic await import('cjs-pkg') for reliable interop.
2. Omitting File Extensions in ESM Imports
Explanation: Node.js ESM resolution requires explicit file extensions. import { foo } from './utils' throws ERR_MODULE_NOT_FOUND because the runtime does not perform implicit .js or .ts resolution.
Fix: Always include extensions: import { foo } from './utils.js'. Configure your editor and linter to enforce this rule. TypeScript's moduleResolution: "NodeNext" or "Bundler" handles this automatically during compilation.
3. Overusing Barrel Files with Wildcard Exports
Explanation: export * from './module.js' breaks static analysis in several bundlers. The tool cannot determine which exports are actually consumed, forcing it to include the entire module graph.
Fix: Use explicit named re-exports in barrel files. If you must use wildcards, verify tree-shaking output with bundle analyzers and consider splitting barrels by feature domain.
4. Mixing require() and import in the Same File
Explanation: ESM and CJS have different loading semantics and caching behaviors. Mixing them in a single file causes unpredictable initialization order, duplicate module instances, and scope conflicts.
Fix: Choose one module system per file. If migrating legacy code, isolate CJS files with .cjs extensions and import them via dynamic await import() from ESM files.
5. Ignoring this Context Differences at Module Scope
Explanation: In CJS, top-level this refers to module.exports. In ESM, top-level this is undefined. Code relying on this for module state or binding will fail silently or throw in ESM.
Fix: Avoid top-level this entirely. Use explicit module-scoped variables or class instances. If migrating CJS code, replace this.property = value with const state = { property: value }; export { state };.
6. Mutating Configuration Objects Across Modules
Explanation: Modules are singletons in JavaScript. If a config object is exported and mutated by one consumer, all other consumers see the mutated state, causing hard-to-trace bugs.
Fix: Freeze configuration objects at export time: export const config = Object.freeze({ ... }); Use immutable patterns or factory functions for environment-specific variants.
7. Relying on Implicit Directory Resolution
Explanation: import './utils' may resolve to ./utils/index.js in some environments but fail in strict ESM runtimes. Implicit resolution creates fragile imports that break across toolchains.
Fix: Always reference the exact file path. Use explicit ./utils/index.js or configure path aliases in tsconfig.json/jsconfig.json with explicit extension mapping.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| New application development | Pure ESM with "type": "module" | Native runtime support, optimal tree-shaking, async loading | Low (zero interop overhead) |
| Legacy Node.js codebase migration | Dual publishing with .cjs/.mjs extensions | Maintains backward compatibility while enabling ESM adoption | Medium (build pipeline complexity) |
| Library author publishing to npm | Explicit exports map + conditional exports | Prevents internal leakage, supports multiple environments | Low (one-time configuration) |
| Browser-only frontend app | ESM with import maps or Vite/Rollup | Eliminates bundler overhead for modern browsers, native module loading | Low (reduced build time) |
| High-frequency trading / low-latency | Static ESM imports only | Avoids dynamic import latency, predictable initialization order | Low (performance gain) |
Configuration Template
{
"name": "@your-org/module-archetype",
"version": "1.0.0",
"type": "module",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
},
"./utils": {
"import": "./dist/utils.js",
"types": "./dist/utils.d.ts"
}
},
"imports": {
"#internal/constants": "./src/constants.js",
"#internal/errors": "./src/errors.js"
},
"scripts": {
"build": "tsc && rollup -c",
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js"
},
"devDependencies": {
"typescript": "^5.4.0",
"rollup": "^4.0.0"
}
}
// tsconfig.json
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ES2022",
"strict": true,
"declaration": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
Quick Start Guide
- Initialize the project: Run
npm init -y and set "type": "module" in package.json. Create src/ and dist/ directories.
- Configure TypeScript: Add the
tsconfig.json template above. Run npx tsc --init if starting fresh, then apply the module resolution settings.
- Create your first module: Write
src/core.ts with named exports and explicit imports. Use #internal/ aliases for private utilities.
- Build and verify: Run
npx tsc. Check dist/ output. Use node --experimental-specifier-resolution=node dist/core.js to test execution. Validate that no CJS syntax remains.
- Test interop: Create a test file using dynamic
await import() to load your module. Verify that static imports resolve correctly and that tree-shaking eliminates unused exports.