Publishing your TypeScript package as both ECMAScript Modules (ESM) and CommonJS (CJS) is the only way to ensure compatibility across the vast majority of Node.js and browser environments.
Let’s see this in action. Imagine you have a simple utility function in src/index.ts:
export function greet(name: string): string {
return `Hello, ${name}!`;
}
After configuring your tsconfig.json and build scripts (we’ll get to that), you’d publish a package that looks something like this in your dist folder:
dist/
├── esm/
│ ├── index.js // ESM version of greet
│ └── index.d.ts // ESM types
└── cjs/
├── index.js // CJS version of greet
└── index.d.ts // CJS types
When a user installs your package, Node.js or bundlers will intelligently pick the correct format based on their package.json’s type field and the import/require syntax they use.
The core problem this solves is the ongoing divergence between how modules are handled in Node.js (historically CJS with require) and the browser (ESM with import), and how even modern Node.js is embracing ESM. Without dual builds, your package will inevitably break for a significant portion of your users.
Here’s the mental model:
- Source: Your TypeScript code (
.tsfiles). - Compilation:
tsc(the TypeScript compiler) is your primary tool. You’ll run it twice, with different configurations. - Output Directories: You need separate output directories for each module format.
dist/esmanddist/cjsare common conventions. tsconfig.json: This is where the magic happens. You’ll have a basetsconfig.jsonand then extend it for each build target.- For ESM: You’ll set
"module": "ESNext"(or"ES2020", etc.) and"outDir": "./dist/esm". Crucially, you don’t want"esModuleInterop": trueor"allowSyntheticDefaultImports": truefor the ESM build if you want pure ESM output. - For CJS: You’ll set
"module": "CommonJS"and"outDir": "./dist/cjs". Here,"esModuleInterop": trueis generally beneficial.
- For ESM: You’ll set
package.json: This is how consumers of your package know which files to load."type": "module": This tells Node.js to treat.jsfiles in your package as ESM by default."exports": This is the modern way to expose your package’s entry points and differentiate between module formats.
This tells bundlers and Node.js: "If the user uses"exports": { "import": "./dist/esm/index.js", "require": "./dist/cjs/index.js", "./package.json": "./package.json" }import x from 'your-package', loaddist/esm/index.js. If they useconst x = require('your-package'), loaddist/cjs/index.js.""main"and"module": These are older fields. You should still include them for backward compatibility with older tooling."main"typically points to the CJS entry point (dist/cjs/index.js), and"module"points to the ESM entry point (dist/esm/index.js).
Here’s a common setup:
tsconfig.base.json (for shared options)
{
"compilerOptions": {
"target": "ES2020",
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"sourceMap": true,
"esModuleInterop": true, // Keep this for CJS build, can be overridden
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"outDir": "./dist" // Base outDir, will be overridden
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}
tsconfig.esm.json (extends base for ESM)
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"module": "ESNext",
"outDir": "./dist/esm",
"esModuleInterop": false, // Disable for pure ESM
"allowSyntheticDefaultImports": false // Disable for pure ESM
}
}
tsconfig.cjs.json (extends base for CJS)
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"module": "CommonJS",
"outDir": "./dist/cjs"
// esModuleInterop: true is inherited from base, which is good here.
}
}
package.json
{
"name": "my-dual-build-package",
"version": "1.0.0",
"description": "A package demonstrating dual ESM/CJS builds.",
"type": "module", // Essential for Node.js ESM resolution
"main": "dist/cjs/index.js", // Fallback for older tools
"module": "dist/esm/index.js", // For bundlers that support it
"types": "dist/esm/index.d.ts", // Primary types (often ESM is preferred)
"exports": {
".": {
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.js",
"types": "./dist/esm/index.d.ts" // Explicitly map types for ESM
},
"./package.json": "./package.json"
},
"scripts": {
"build:esm": "tsc --project tsconfig.esm.json",
"build:cjs": "tsc --project tsconfig.cjs.json",
"build": "npm run build:esm && npm run build:cjs",
"prepublishOnly": "npm run build" // Ensure build before publishing
},
"devDependencies": {
"typescript": "^5.0.0" // Or your TS version
}
}
The exports field is the most powerful part of this setup. It allows you to precisely control how your package is resolved. When a user writes import { greet } from 'my-dual-build-package';, Node.js or a bundler checks package.json. Seeing "type": "module" and the exports field, it prioritizes the "import" condition, pointing to dist/esm/index.js. If the user uses const { greet } = require('my-dual-build-package');, it looks for the "require" condition and uses dist/cjs/index.js. The "types" field within "exports" explicitly tells tools where to find the TypeScript definitions for the ESM import.
A common pitfall is forgetting to update the exports field to correctly point to your dist/esm and dist/cjs outputs. Without this, consumers might get the wrong module format, leading to runtime errors like TypeError [ERR_UNSUPPORTED_ESM_URL_SCHEME] or issues with require in ESM contexts.
The next logical step after mastering dual builds is understanding how to handle package subpath exports, allowing you to expose specific modules within your package (e.g., import { specificUtil } from 'my-dual-build-package/utils';).