Publishing a Node.js package that works seamlessly for both CommonJS (CJS) and ECMAScript Modules (ESM) users is surprisingly straightforward once you understand the exports field in package.json.
Let’s see this in action. Imagine we have a simple utility package named my-utils.
// package.json
{
"name": "my-utils",
"version": "1.0.0",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"exports": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
},
"type": "module", // Or "commonjs"
"scripts": {
"build": "tsc"
},
"devDependencies": {
"typescript": "^5.0.0"
}
}
Here’s a basic TypeScript file that will be compiled to both formats:
// src/index.ts
export function greet(name: string): string {
return `Hello, ${name}!`;
}
And a tsconfig.json to handle the dual output:
// tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "NodeNext", // Crucial for dual output
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
When a user installs my-utils and imports it:
For ESM users:
// consumer-esm/index.mjs
import { greet } from 'my-utils';
console.log(greet('World')); // Output: Hello, World!
Node.js will look at package.json, see the "exports" field, and prioritize the "import" condition because the consumer is using an ESM import (import ... from ...). It will resolve to ./dist/index.mjs.
For CJS users:
// consumer-cjs/index.js
const { greet } = require('my-utils');
console.log(greet('Node')); // Output: Hello, Node!
Node.js, seeing the require() call, will check the "exports" field for the "require" condition and resolve to ./dist/index.cjs.
The type field in package.json (set to "module" in our example) tells Node.js the default module system for .js files within the package. If it’s "module", .js files are treated as ESM. If it’s "commonjs", they are treated as CJS. However, the "exports" field overrides this default based on the import/require syntax used by the consumer.
The main and module fields are now considered legacy for determining entry points when exports is present. They are primarily for older Node.js versions or tools that don’t fully support the exports field. If exports is defined, Node.js will use it.
The "exports" field is a powerful mechanism for controlling exactly which files are exposed and under what conditions. You can even define subpath exports for more granular control. For instance, to expose a specific utility function directly:
// package.json (extended exports)
{
// ... other fields
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
},
"./greet": {
"import": "./dist/greet.mjs",
"require": "./dist/greet.cjs"
}
}
}
This allows consumers to do:
// ESM
import { greet } from 'my-utils/greet';
// CJS
const { greet } = require('my-utils/greet');
The module: NodeNext in tsconfig.json is key here. It instructs the TypeScript compiler to output modules compatible with Node.js’s ESM resolution strategy, which respects the "exports" field and the "type" field in package.json. It generates .mjs files for ESM and .cjs files for CJS, aligning with Node.js conventions.
One subtle but critical aspect is how TypeScript handles esModuleInterop and allowSyntheticDefaultImports when targeting NodeNext. Setting "esModuleInterop": true is essential for generating CJS output that correctly handles default exports from ESM-like sources, and for allowing CJS consumers to require ESM packages more gracefully. It bridges the gap by creating "interop" wrappers, ensuring that require can access default exports as module.exports.default and that ESM can import CJS modules without issues.
The declaration: true option is for generating .d.ts files, providing type information for consumers, which is crucial for a good developer experience, especially when dealing with dual module formats.
The most common pitfall is forgetting to set "type": "module" in your package.json when your source code is primarily ESM, or vice-versa, leading to incorrect default interpretations by Node.js if the exports field isn’t perfectly aligned. Even with "exports", the type field influences how files not explicitly mapped in exports are treated.
The next hurdle is understanding how to handle conditional exports for different Node.js versions or environments.