37.4 Supporting Multiple Module Formats: ESM and CJS
Right, so you’ve decided to be a proper library author. Congratulations, and my condolences. You’re about to enter the special hell of making your beautiful TypeScript code play nicely with the entire JavaScript ecosystem’s messy, decades-spanning module system. The goal is simple: someone using old-school require() in a CommonJS (CJS) app should be able to use your library, and someone using shiny import in an ESM app should be able to use it too, all without them ever knowing about the duct tape and baling wire you used behind the scenes.
Let’s be blunt: this is mostly a packaging and distribution problem, not a writing-code problem. You write in ESM. It’s 2024. You’re not a monster. The trick is getting your build process to output the right artifacts in the right formats and then telling Node.js how to find them. Fail, and you’ll be fielding confusing “Module not found” issues on GitHub until the heat death of the universe.
The Heart of the Matter: package.json Entries
Your package.json is the map that Node follows. For a dual-mode package, these fields are non-negotiable:
{
"name": "my-awesome-lib",
"version": "1.0.0",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"require": "./dist/index.cjs",
"import": "./dist/index.mjs",
"types": "./dist/index.d.ts"
},
"./styles.css": "./dist/styles.css"
},
"files": ["dist/"],
"scripts": {
"build": "tsc && node build.js"
}
}
Here’s the play-by-play:
main: The old fallback for tools that don’t know aboutexports. Point it to your CommonJS entry.module: A non-standard but widely understood hint to bundlers (like Webpack and Rollup) that there’s an ESM version here. Point it to your ESM entry.types: The entry point for your type definitions so TypeScript users get autocomplete and type checks.exports: The modern, definitive solution. This is a map that allows you to define different entry points for different conditions (likerequirevs.import). It’s also how you lock down your public API—any file not listed inexportsis effectively private. This is why you’re using it.
Your Build Process: Generating the Two Formats
You write your source code in ESM (import/export). You can’t just serve that directly because if a CJS app requires an ESM file, Node.js will throw a tantrum. So you need to compile to both formats.
Forget doing this manually. Use a tool. tsc can do it, but it’s a bit clunky. I prefer using tsc to generate declaration files (.d.ts) and then using a dedicated bundler like esbuild for the heavy lifting because it’s approximately a million times faster.
Here’s a simple Node.js build script (build.js) using esbuild:
import { build } from 'esbuild';
import { readFileSync } from 'fs';
const pkg = JSON.parse(readFileSync('./package.json', 'utf8'));
// Shared settings
const shared = {
entryPoints: ['src/index.ts'],
bundle: true,
platform: 'node',
// This is crucial: it marks built-in modules (fs, path, etc.) as external
// so they aren't bundled into your package.
external: [
...Object.keys(pkg.dependencies || {}),
...Object.keys(pkg.peerDependencies || {}),
],
};
// Build ESM (.mjs)
await build({
...shared,
outfile: 'dist/index.mjs',
format: 'esm',
});
// Build CommonJS (.cjs)
await build({
...shared,
outfile: 'dist/index.cjs',
format: 'cjs',
});
console.log('Build complete.');
Run this with node build.js. You now have two files: index.mjs for ESM and index.cjs for CJS. The .mjs and .cjs extensions are your strongest signal to Node.js about the module format. Use them.
The TypeScript Configuration
Your tsconfig.json needs to be set up for this dance. You’re targeting modern JS, but the real magic is in the module and moduleResolution settings.
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "node",
"declaration": true,
"outDir": "dist",
"strict": true,
"skipLibCheck": true // You often need this when generating .d.ts files for dual packages.
},
"include": ["src/**/*"]
}
Key point: "module": "ESNext" tells TypeScript to keep your import/export statements intact in the emitted JS, which is what you want for the esbuild step. tsc isn’t generating your final CJS build; esbuild is.
The Devil’s in the Default Export
Here’s a classic foot-gun. In ESM, you can have a default export:
// src/index.ts
export default function myLib() { ... }
This works beautifully in ESM land:
import myLib from 'my-awesome-lib';
But when this gets compiled to CJS, it becomes:
// dist/index.cjs
exports.default = myLib;
This means a CJS user gets a nasty surprise:
const myLib = require('my-awesome-lib'); // { default: [Function: myLib] }
They have to write require('my-awesome-lib').default, which is ugly and inconsistent. The solution? Just don’t use default exports. Use named exports. They work identically in both systems.
// Do this instead
export function myLib() { ... }
Then everyone, in both worlds, can use it predictably:
import { myLib } from 'my-awesome-lib'; // ESM
const { myLib } = require('my-awesome-lib'); // CJS
If you absolutely must have a default export for backwards compatibility, you’ll have to get creative with your CJS build wrapper, but trust me, the headache isn’t worth it. Named exports are your friend.
Testing This Mess
You can’t just assume it works. You must test both import paths. The easiest way is to have two simple test files:
test-esm.mjs:
import { myLib } from 'my-awesome-lib';
console.log('ESM works!');
test-cjs.cjs:
const { myLib } = require('my-awesome-lib');
console.log('CJS works!');
Run them with node test-esm.mjs and node test-cjs.cjs. If both pass, you’ve successfully appeased the ancient and modern gods of JavaScript. Now go publish, and welcome to the wonderful, slightly deranged world of library maintenance.