Skip to main content

Dual Publishing ESM and CJS Modules with tsup and Are the Types Wrong?

· 7 min read
John Reilly
OSS Engineer - TypeScript, Azure, React, Node.js, .NET

If you need to publish a package that supports both ECMAScript modules (ESM) and CommonJS modules (CJS), you can use tsup to do so. This post will show you how to do that and how to ensure that the types are correct using the tool Are the Types Wrong?.

title image reading "Dual Publishing ESM and CJS Modules with tsup and Are the Types Wrong?" with the Are the Types Wrong logo

Package support for ESM and CJS with tsup

I recently needed to build a package that supported both ESM and CJS modules. After attempting to implement this myself, and frankly aging ten years, I decided to see if there was a tool out there that could help me. Happily, there are a number of tools that can help with this and I found that the excellent tsup was the easiest to use.

tsup is a zero-config TypeScript bundler with a focus on speed and simplicity. It's a great tool for building and bundling your TypeScript code into ESM and CJS modules and is powered by esbuild.

To get started, you can install tsup to your project:

npm install tsup --save-dev

Then you can add a script to your package.json to build your package:

{
"scripts": {
"build": "tsup src/index.ts --format cjs,esm --dts --clean --sourcemap"
}
}

In this example, we're building the src/index.ts file into both CJS and ESM modules, generating type definitions, cleaning up the output directory prior to build, and generating sourcemaps. You can, if you like, use a tsup.config.ts file to configure tsup but I found the above to be sufficient for my needs.

When you run npm run build, you'll see that tsup has created a dist directory with the following structure:

dist/
index.cjs
index.cjs.map
index.d.cts
index.js
index.js.map
index.d.ts

The index.js and index.js.map files are the ESM modules and the index.cjs and index.cjs.map files are the CJS modules. The index.d.ts and index.d.cts files contain the type definitions.

This is great news! You now have a package that supports both ESM and CJS modules. But now we need to get our package.json file set up correctly.

Linting your package.json file with Are the Types Wrong?

When you're publishing a package, it's important to ensure that your package.json file is set up correctly. It is the manifest that other packages consume. If it is not set up correctly, things won't work at runtime and you'll have an unpleasant engineering experience in your IDE as well. Getting it right includes ensuring that the exports field is set up correctly to support both ESM and CJS modules. This can be a bit tricky to get right, so it's a good idea to use a tool to help you.

I made the following additions to my package.json file to support both ESM and CJS modules:

{
"//": "This is the correct way to set up a package with a `src/index.ts` root file that supports both ESM and CJS modules.",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"import": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.cts",
"require": "./dist/index.cjs"
}
}
}

Let's break this down:

  • The type field is set to module to indicate that the package supports ESM modules.
  • The main field is set to ./dist/index.cjs to indicate the entry point for CJS modules.
  • The module field is set to ./dist/index.js to indicate the entry point for ESM modules.
  • The types field is set to ./dist/index.d.ts to indicate the type definitions file for ESM modules.

We then set up the exports field to support both ESM and CJS modules. The import field is set up to support ESM modules and the require field is set up to support CJS modules. In each, the types field is set to the relevant type definitions file - significantly, the types comes first.

Here I've jumped straight to the good part here. I've given you the correct way to set up the exports field for a package that supports both ESM and CJS modules. But how do you know if your package.json file is set up correctly?

Well, in my case I used the tool Are the Types Wrong to lint my package.json file. Are the Types Wrong is a tool that checks your package.json file to ensure that it's set up correctly for publishing a package that supports both ESM and CJS modules. You're possibly aware of the website but did you know that there's a CLI tool that you can use to lint your package.json file?

There is.

If you would like to read the full documentation on the CLI tool, you can find it here. However, if you're like me and in a hurry, you can just use it like this:

  • cd into your project directory
  • run npx --yes @arethetypeswrong/cli --pack .
  • et voila, your package.json file will be linted for type issues

To see what this looks like, here's the output from running npx @arethetypeswrong/cli in the project directory of a package with the package.json file not set up correctly:

api v1.0.0

Build tools:
- typescript@^5.2.2

❌ Import resolved to JavaScript files, but no type declarations were found. https://github.com/arethetypeswrong/arethetypeswrong.github.io/blob/main/docs/problems/UntypedResolution.md


┌───────────────────┬─────────────┐
│ │ "api"
├───────────────────┼─────────────┤
│ node10 │ ❌ No types │
├───────────────────┼─────────────┤
│ node16 (from CJS) │ ❌ No types │
├───────────────────┼─────────────┤
│ node16 (from ESM) │ ❌ No types │
├───────────────────┼─────────────┤
│ bundler │ ❌ No types │
└───────────────────┴─────────────┘

By contrast, here's how it looks when you use the correct package.json file:

shared v1.0.0

Build tools:
- typescript@^5.4.5
- tsup@^8.1.0

No problems found 🌟


┌───────────────────┬──────────┐
│ │ "api"
├───────────────────┼──────────┤
│ node10 │ 🟢 │
├───────────────────┼──────────┤
│ node16 (from CJS) │ 🟢 (CJS)
├───────────────────┼──────────┤
│ node16 (from ESM) │ 🟢 (ESM)
├───────────────────┼──────────┤
│ bundler │ 🟢 │
└───────────────────┴──────────┘

Isn't that great? You can now be confident that your package.json file is set up correctly for publishing a package that supports both ESM and CJS modules, both from a runtime code and a types perspective. So the full package.json might look something like this:

{
"name": "our-package",
"version": "1.0.0",
"description": "",
"scripts": {
"build": "tsup src/index.ts --format cjs,esm --dts --clean --sourcemap"
},
"author": "",
"license": "ISC",
"dependencies": {},
"devDependencies": {
"tsup": "^8.1.0",
"typescript": "^5.4.5"
},
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"import": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.cts",
"require": "./dist/index.cjs"
}
}
}

Conclusion

In this post, we've seen how to publish a package that supports both ESM and CJS modules using tsup. We've also seen how to ensure that the types are correct using the tool Are the Types Wrong.

It's probably worth saying that Are the Types Wrong doesn't check everything, and that tsups method of dual compilation makes some unchecked assumptions too. Using these tools isn't a silver bullet, given how complicated the ESM/CJS compatibility story is, but they do their best to help.

Thanks to Andrew Branch for putting together Are the Types Wrong and to the folk that work on tsup for making it easy to build packages that support both ESM and CJS modules. Thanks also to Andrew for his help in reviewing this post.

Remember, friends don't let friends publish packages with incorrect types!