pe": "module",
"exports": {
".": "./dist/index.js",
"./utils": "./dist/utils.js"
},
"engines": {
"node": ">=18.0.0"
}
}
**Rationale:**
- `"type": "module"`: Explicitly declares ESM. This prevents ambiguity and ensures consistent behavior across environments.
- `"exports"`: Defines the public API surface. This is critical for ESM packages to control entry points and enable subpath imports.
- `"engines"`: ESM features like `import.meta` and stable JSON imports require Node.js 18+.
#### 2. Module Implementation
ESM enforces explicit imports. Relative imports must include the file extension.
```typescript
// src/services/metrics-collector.ts
export interface MetricPayload {
event: string;
timestamp: number;
value: number;
}
export class MetricsCollector {
private buffer: MetricPayload[] = [];
async record(payload: MetricPayload): Promise<void> {
this.buffer.push(payload);
if (this.buffer.length >= 100) {
await this.flush();
}
}
private async flush(): Promise<void> {
// Simulated network call
console.log(`Flushing ${this.buffer.length} metrics`);
this.buffer = [];
}
}
// src/index.ts
import { MetricsCollector } from './services/metrics-collector.js';
import { createServer } from 'node:http';
const collector = new MetricsCollector();
const server = createServer(async (req, res) => {
if (req.url === '/track') {
await collector.record({
event: 'api_call',
timestamp: Date.now(),
value: 1,
});
res.writeHead(200);
res.end('OK');
} else {
res.writeHead(404);
res.end();
}
});
server.listen(3000, () => {
console.log('Server running on port 3000');
});
Key Implementation Details:
- Extension Requirement:
import ... from './services/metrics-collector.js' includes .js. Even in TypeScript source, the import path references the emitted JavaScript extension. This is required by the ESM resolver.
- Named Exports: Using
export class and export interface allows consumers to import specific bindings. This supports better tree-shaking compared to default exports.
- Node Built-ins:
import { createServer } from 'node:http' uses the node: protocol, which is the recommended pattern for core modules in ESM.
3. TypeScript Integration
TypeScript must be configured to respect the type field and enforce ESM resolution rules.
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"strict": true,
"esModuleInterop": true
},
"include": ["src/**/*"]
}
Rationale:
"module": "NodeNext": Instructs TypeScript to emit ESM syntax and respect package.json type.
"moduleResolution": "NodeNext": Enforces ESM resolution rules, including extension requirements.
- This configuration ensures TypeScript compilation aligns with Node.js runtime behavior, preventing mismatches between build and runtime.
4. Extension Overrides
File extensions can override the type field for specific files. This is useful for gradual migration or integrating legacy code.
.mjs: Always treated as ESM.
.cjs: Always treated as CommonJS.
.mts / .cts: TypeScript equivalents for ESM and CJS respectively.
Example: A legacy utility that must remain CJS in an ESM project.
// src/legacy-adapter.cjs
module.exports = {
transformData: (input) => {
return input.map((item) => item.toUpperCase());
},
};
// src/modern-consumer.mjs
import { createRequire } from 'node:module';
import { transformData } from './legacy-adapter.cjs';
// In ESM, you can import CJS files using the .cjs extension.
// The CJS exports are mapped to the default export.
console.log(transformData(['hello', 'world']));
Pitfall Guide
Production environments expose subtle failures in module configuration. The following pitfalls are common in ESM migrations and hybrid setups.
| Pitfall | Explanation | Fix |
|---|
| Missing File Extensions | ESM requires explicit extensions for relative imports. Omitting .js causes ERR_MODULE_NOT_FOUND. | Always append .js to relative imports in TypeScript and JavaScript source files. |
__dirname Undefined | ESM does not provide __dirname or __filename. Code relying on these fails at runtime. | Use import.meta.url with fileURLToPath and dirname from node:path. |
| Dynamic Import Protocol | Dynamic import() in ESM requires the file:// protocol for local files. Relative paths fail. | Use import('file://' + path.resolve('./module.js')) or import.meta.resolve. |
| JSON Import Syntax | ESM JSON imports require import assertions or specific flags depending on Node version. | Use import data from './data.json' assert { type: 'json' } or read via fs for compatibility. |
| Default Export Mismatch | CJS module.exports = fn maps to ESM default export, but named imports fail. | Access via import legacy from './legacy.cjs' or use createRequire for named access. |
require in ESM | require() is not defined in ESM scope. Attempting to use it throws ReferenceError. | Use dynamic import() for conditional loading or createRequire if synchronous loading is mandatory. |
| Extensionless Package Imports | ESM resolves package imports based on exports field. Missing exports can cause resolution failures. | Ensure dependencies define exports in their package.json. Avoid relying on implicit file resolution. |
Best Practice: Path Resolution Helper
Create a utility to handle path resolution consistently across ESM and CJS contexts.
// src/utils/path-resolver.ts
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
export function resolveProjectPath(...segments: string[]): string {
return resolve(__dirname, '..', ...segments);
}
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| New Project | "type": "module" | Aligns with browser standards, enables static analysis, future-proofs codebase. | Low. Modern tooling supports ESM natively. |
| Legacy Monolith | "type": "commonjs" | Maintains stability, avoids breaking existing require patterns and dependencies. | Low risk, but accumulates technical debt as ecosystem shifts. |
| Gradual Migration | "type": "module" + .cjs files | Allows incremental conversion. New code uses ESM; legacy code remains CJS via extension override. | Medium. Requires managing mixed syntax and interop patterns. |
| Library Author | Dual Package (exports field) | Supports both CJS and ESM consumers. Maximizes compatibility for downstream users. | High. Requires build configuration to emit both formats. |
| Browser-First App | "type": "module" | Eliminates bundling overhead for module syntax. Enables direct browser execution. | Low. Build tools optimize ESM efficiently. |
Configuration Template
package.json
{
"name": "@acme/production-service",
"version": "1.0.0",
"type": "module",
"main": "./dist/index.js",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"./utils": {
"import": "./dist/utils.js",
"types": "./dist/utils.d.ts"
}
},
"scripts": {
"build": "tsc",
"start": "node dist/index.js"
},
"engines": {
"node": ">=18.0.0"
}
}
tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"declaration": true,
"outDir": "./dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
Quick Start Guide
- Initialize Project: Run
npm init -y to create package.json.
- Set Module Type: Add
"type": "module" to package.json.
- Configure TypeScript: Create
tsconfig.json with "module": "NodeNext" and "moduleResolution": "NodeNext".
- Write ESM Code: Create
src/index.ts. Use import/export syntax. Ensure relative imports include .js extensions.
- Build and Run: Execute
npm run build followed by npm start. Verify output and resolve any extension or path errors.
This configuration establishes a robust, standards-compliant foundation for Node.js development. By explicitly controlling module resolution through package.json and adhering to ESM constraints, teams can leverage modern JavaScript features while maintaining compatibility with the broader ecosystem.