Publishing dual ESM+CJS packages
Say you want to publish an npm package that needs to work in both ESM and CommonJS environments, and also have type definitions. This is what is known as a “dual package” and doing it correctly is a tedious process where a dozen little things can wreak havoc at any point.
I haven’t seen many folks write about this, so I’ve had to slowly build up my knowledge from multiple sources over many years. I actually started writing this guide over a year ago, but it feels like every time I get closer to publishing it I find new information and need to make more changes.
Someone once said to me, “don’t sit in the room with a three-year old baby — give birth!” So that’s what I’m about to do.
Start with ESM
ES modules are obviously the future, so our package should be ESM-first. I wish Node would just make this mode the default, but for now we need to manually opt-in by adding a "type"
to our package.json
:
"type": "module"
This unlocks all the nice ESM features, like import
statements, in regular .js
files. No need for .mjs
or anything weird.
The simplest thing we can do after this is define package entry points using "exports"
, starting with the main entry point (the “barrel” 🛢️).
"exports": { ".": "./index.js"}
You can define all the other entry points manually, but if you’re lazy or don’t care, you can also use a wild card. You can totally go wild (ha!) in this section, but it’s generally a good idea to mimick the source file structure. For a good number of cases, this is all we need tbh.
"exports": { ".": "./index.js", "./*": "./*"}
The only other thing we must make sure is to follow the rules of ES modules inside our source files. Things like, using full relative paths (including file extensions). To help with auto-imports, I like to add these two options in my .vscode/settings.json
:
"javascript.preferences.importModuleSpecifierEnding": "js","typescript.preferences.importModuleSpecifierEnding": "js",
At this point, our package correctly supports ESM. This should always be our primary goal for all packages in 2023+. Ideally, I would like to be able to end this blog post here.
CommonJS in the year of our lord 2023
For better or worse, many of us still need to support CommonJS. It’s painful, but starting with ESM makes it less painful than the other way around. I would reframe the concept of dual package as “an ESM-first package that happens to also support CJS as a courtesy”.
We’ll need to bring in a tool to transpile our ESM code to CJS. Two popular choices are esbuild
and swc
. I prefer the latter, but it doesn’t really matter; just please don’t do this conversion by hand. Both of these tools even have playgrounds which you can use instead of converting manually.
Let’s look at a simple case from a high level first. With the help of esbuild
/swc
, we can produce an index.cjs
right next to our ESM index.js
. And then add it in two places in our package.json: under conditional "exports"
and also in the "main"
field for better compatibility.
"type": "module","main": "./index.cjs","exports": { ".": { "import": "./index.js", "require": "./index.cjs" }}
This seems manageable for small single-file packages, but it gets real messy real fast if there are too many files. A better approach would be to put our ESM and CJS files in separate directories (call them esm
and cjs
respectively because why not).
"type": "module","main": "./cjs/index.cjs","exports": { ".": { "import": "./esm/index.js", "require": "./cjs/index.cjs" }, "./*": { "import": "./esm/*", "require": "./cjs/*" }}
This doesn’t seem too bad. A simple script ought to do it!
Enter TypeScript
This is the part where things get a little hairy. Historically, TypeScript has always refused to take ES modules seriously, causing endless pain in the ecosystem for no good reason. I will try to skip the rant and focus on what works today, because the situation has greatly improved.
Essentially, the consumer of our package needs to set the value of "moduleResolution"
to "nodenext"
or "bundler"
. For some reason, "node"
is actually an alias for "node10"
so it won’t work with ESM features. Not confusing at all!
Inside our package, the easiest path forward is to colocate our type declaration files right next to their respective ESM/CJS files. For example: index.js
will have an index.d.ts
in the same folder, and index.cjs
will have an index.d.cts
in the same folder. Doing this should make everything work automatically and we won’t need to add "types"
export conditions.
"type": "module","main": "./cjs/index.cjs","types": "./cjs/index.d.cts", // not needed"exports": { ".": { "import": { "types": "./esm/index.d.ts", // not needed "default": "./esm/index.js" }, "require": { "types": "./cjs/index.d.cts", // not needed "default": "./cjs/index.cjs" }}
Cool! Are we done? We would be, if TypeScript allowed us to emit .d.cts
and .cjs
files from a single .ts
source. But it doesn’t! TypeScript does not give a fuck, it will happily produce a .mjs
file containing CommonJs syntax because doing the right thing would be against its “design goals”.
There are multiple ways to work around this. If we keep using the TypeScript compiler (tsc
) to generate all our output, then both our esm/
and cjs/
directories will only contain .js
files. However, we can add a dummy package.json file inside cjs/
to treat all .js
files in this folder as CommonJS. This feels hacky but is simple and effective!
{ "type": "commonjs" }
(Generally the build output folders are gitignored, so we’ll probably need to unignore this file specifically, or dynamically generate it in a post-build script.)
Alternatively, we can use esbuild
/swc
to generate the ESM/CJS outputs from TS sources, and leverage tsc
solely for the purpose of generating type definitions (using emitDeclarationOnly
). These tools are faster, don’t have arbitrary file extension limitations, and the output is nicer too (the tsc
output has some quirks and limitations).
"scripts": { "build": "build:esm && build:cjs && build:dts", "build:esm": "swc src -d esm -C module.type=es6", "build:cjs": "swc src -d cjs -C module.type=commonjs", "build:dts": "tsc src/*.ts --outDir types --declaration --emitDeclarationOnly"}
This time we will need to specify the "types"
conditions because our type definitions don’t live next to their respective ESM/CJS files.
"type": "module","main": "./cjs/index.cjs","types": "./types/index.d.ts","files": ["esm","cjs","types"],"exports": { ".": { "import": { "types": "./types/index.d.ts", "default": "./esm/index.js" }, "require": { "types": "./types/index.d.ts", "default": "./cjs/index.cjs" }}
And that’s all we need to do to pacify the module gods today! This setup works well for large projects that need to target all kinds of environments. The best part is that we have full control over it, since we are basically writing it from scratch.
Automate it?
I’ve tried all kinds of tools for making this process easier. To name a few: vite
, microbundle
, preconstruct
, tsup
, unbuild
, pkgroll
, and now tshy
. I’ve found them all to do too much or too little or be too opinionated or too inflexible or simply too broken.
Some of these tools can indeed work well for smaller packages, but none of them check all the boxes for me when it comes to large projects. Your mileage may wary, so of course give them a try! I would probably avoid any solution that defaults to bundling (rather than transpiling), because it makes the package very difficult to debug for the consumer and can also cause tree-shaking issues.