Publishing dual ESM+CJS packages
Published on September 15, 2023 DATE
Say you want to publish an npm package that needs to work in both ESM ORG 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 CARDINAL 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 DATE . I actually started writing this guide over a year ago DATE , 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 DATE baby — give birth!” So that’s what I’m about to do.
ES modules are obviously the future, so our package should be ESM ORG -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 ORG 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” 🛢️ PRODUCT ).
"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" , " ./* " : "./*" }
Of course, don’t forget all the other important parts of package.json — like "files" , because defining our exports does not automatically include them in the published package.
This is what a complete, ESM ORG -compatible package.json could look like:
{ " name " : "my-package" , " version " : " 1.69.0 CARDINAL " , " type " : "module" , " license " : " MIT ORG " , " files " : [ "*.js" ], " exports " : { " . " : "./index.js" , " ./* " : "./*" } }
The only other thing we must make sure is to follow the rules of ES ORG modules inside our code. Things like, correct import and export syntax, with imports using full relative paths (including file extensions). To help with auto-imports, I like to add these two CARDINAL options in my .vscode/settings.json :
"javascript.preferences.importModuleSpecifierEnding" : "js" , "typescript.preferences.importModuleSpecifierEnding" : "js" ,
At this point, our package correctly supports ESM ORG . This should always be our primary goal for all packages in 2023 DATE +. Ideally, I would like to be able to end this blog post here.
For better or worse, many of us still need to support CommonJS. It’s painful, but starting with ESM ORG makes it less painful than the other way around. I would reframe the concept of dual package as “an ESM ORG – first ORDINAL package that happens to also support CJS ORG as a courtesy”.
We’ll need to bring in a tool to transpile our ESM ORG code to CJS ORG . Two CARDINAL 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 ORDINAL . With the help of esbuild / swc ORG , we can produce an index.cjs right next to our ESM ORG index.js . And then add it in two CARDINAL 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 ORG and CJS ORG 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!
This is the part where things get a little hairy. Historically, TypeScript ORG has always refused to take ES ORG 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 DATE , 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 ORG 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 ORG /CJS files. For example: index.js will have an index.d.ts NORP 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 PERSON /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 ORG allowed us to emit .d.cts and .cjs files from a single .ts source. But it doesn’t! TypeScript ORG does not give a fuck, it will happily produce a .mjs file containing CommonJs ORG 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 ORG compiler ( tsc ORG ) 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!
cjs/package.json { " 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 ORG /CJS outputs from TS ORG 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 && ORG build: cjs && ORG 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 ORG 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 ORG /CJS files.
"type" : "module" , "main" : "./cjs/index.cjs" , "types" : "./types/ index.d.ts NORP " , "files" : [ "esm" , "cjs" , "types" ] , "exports" : { " . " : { " import " : { " types " : "./types/ index.d.ts NORP " , " default " : "./esm/index.js" }, " require " : { " types " : "./types/ index.d.ts NORP " , " default " : "./cjs/index.cjs" } }
And that’s all we need to do to pacify the module gods today DATE ! 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.
I’ve tried all kinds of tools for making this process easier. To name a few: vite , microbundle GPE , preconstruct GPE , tsup , unbuild PERSON , 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.