How to Easily Support ESM and CJS in Your TypeScript Library
A simple example that works for standalone npm libraries and monorepos
Published on
May 23, 2024
Read time
5 min read
Introduction
JavaScript has two module systems: the newer ESM (ECMAScript Modules), with import
and export
, and the older CJS (CommonJS), with require
and module.exports
.
It’s understandable that a language with so many users and evolving use-cases develops over time. However, this dual system creates additional work — and sometimes headaches — for library developers. Unless you can be certain you’ll only need to support a specific module system, it’s important to support both options to avoid compatibility issues.
I came across this issue because I’m developing a monorepo, where one of my apps uses Next.js, which requires ESM, and another uses AWS Lambdas, which require CJS. They’re both written in TypeScript and they need to share code. So, how can we support both ESM and CJS?
In this article, I’ll share my solution. Though we’ll explore the solution in the context of a monorepo, the exact same steps can be applied to an individual npm library. We’ll be using Yarn Workspaces to manage our monorepo and Typescript’s tsc
binary to compile our code.
You can see a final version of the project here. To follow along, ensure you have Node.js and Yarn installed, and let’s get coding!
Project setup and dependencies
Let’s scaffold a basic monorepo. First, we’ll create a new directory.
mkdir cjs-esm-monorepo-example
cd cjs-esm-monorepo-example
Then we’ll create a filesystem, with an apps
folder for our applications and a packages
folder for shared code. We’ll start with one app, called server
, and one package, called utils
.
yarn init -y
mkdir apps packages
cd apps
mkdir server
cd server
yarn init -y
cd ../..
cd packages
mkdir utils
cd utils
yarn init -y
yarn install -D @types/node npm-run-all typescript
mkdir src
In the utils
folder we installed three dependencies:
@types/node
— to provide type support for Node.js methods.npm-run-all
— which will allow us to run scripts in parallel, regardless of the operating system of the developer.typescript
— to give us access to thetsc
binary.
If you only care about making a standalone library, you can ignore everything outside of the utils
folder!
The package.json files
The root folder
Let’s tweak our package.json
files that have been created. In the root folder, we need to set "private"
to true
(which is required to use workspaces) and then we need to define where the workspaces live.
{
"name": "workspaces",
"version": "1.0.0",
"license": "MIT",
"private": true,
"workspaces": ["packages/*", "apps/*"]
}
The server application
We’ll keep our server
simple, as that’s not the part we’re interested in, and just use plain JavaScript, with an entrypoint of index.js
. If our code in index.js
runs, that’s all that matters!
{
"name": "server",
"version": "1.0.0",
"license": "MIT",
"main": "index.js",
"scripts": {
"postinstall": "yarn --cwd ../../packages/utils build",
"start": "node index.js"
},
"dependencies": {
"utils": "file:../../packages/utils"
}
}
In our dependencies, we can include the utils
package via the file:
protocol, which allows us to link to a local package. I also wanted to ensure that utils
gets built whenever we run yarn install
in this project, so we can achieve this with a "postinstall"
script.
The utils library
Our last package.json
file is in our utils
folder.
{
"name": "utils",
"version": "1.0.0",
"license": "MIT",
"main": "dist/cjs/index.js",
"module": "dist/esm/index.js",
"types": "dist/types/index.d.ts",
"scripts": {
"build:cjs": "tsc -p tsconfig.cjs.json",
"build:esm": "tsc -p tsconfig.esm.json",
"build": "npm-run-all --parallel build:cjs build:esm"
},
"devDependencies": {
"@types/node": "20.12.12",
"npm-run-all": "4.1.5",
"typescript": "5.4.5"
}
}
Here, we specify three different entry points.
"main"
is the entrypoint for CJS."module"
is the entrypoint for ESM."types"
tells our applications where to find thed.ts
file with the types for our package.
For more granular control, we could use the newer "exports"
field to define the entry points, but this is less compatible and was overkill in our case. If you need "exports"
you could use something like this:
"exports": {
".": {
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.js"
}
},
As for the scripts, I’m running tsc
with the p
flag to point to a particular config file for each compiled output.
The tsconfig.json files
Next, let’s define these config files, using a minimal set of configurations. Let’s begin with a tsconfig.base.json
for shared configs:
{
"compilerOptions": {
"rootDir": "./src",
"strict": true,
"moduleResolution": "node"
}
}
Next, for CJS, we have our tsconfig.cjs.json
file:
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"target": "es5",
"module": "commonjs",
"outDir": "./dist/cjs",
"declaration": false
}
}
We have set "declaration"
to false because the declaration file only needs to be created once, and we can do that in our tsconfig.esm.json
file, which looks like this:
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"outDir": "./dist/esm",
"declaration": true,
"declarationDir": "./dist/types"
}
}
Finally, let’s add some code. We’ll create an index.ts
file inside the src
folder, and make a very basic function to add two numbers.
export function add(a: number, b: number): number {
return a + b;
}
Though basic, this file uses ESM “export” syntax and types, so it will need to be compiled. If we run our yarn build
script, we should see a new dist
folder, which subfolders of cjs
, esm
and types
for our delcaration file.
All that’s left is to run it.
Using the library
If we go back into apps/server/index.js
, we can use CJS “require” to use our new add
function.
const { add } = require("utils");
function main() {
console.log(add(1, 5));
}
main();
If we run yarn start
we should see the answer logged to the console!
What about ESM? We can tweak the package.json
file of our server
application to remove "main"
, replace it with "module"
and also set the "type"
to "module"
.
{
"name": "server",
"version": "1.0.0",
"license": "MIT",
"type": "module",
"module": "index.js",
"scripts": {
"postinstall": "yarn --cwd ../../packages/utils build",
"start": "node index.js"
},
"dependencies": {
"utils": "file:../../packages/utils"
}
}
If everything’s set up correctly, yarn start
will still work!
An extra compatibility tip
I also picked up a tip in this article, which wasn’t necessary for my setup, but may improve compatibility in some cases. That article uses a shell script, but executing this can require an additional step of ensuring permissions are granted. For me, it was simplest to add an inline script into the package.json
, like this:
"scripts": {
"build:cjs": "tsc -p tsconfig.cjs.json && echo '{\"type\": \"commonjs\"}' > dist/cjs/package.json",
"build:esm": "tsc -p tsconfig.esm.json && echo '{\"type\": \"module\"}' > dist/esm/package.json",
"build": "yarn npm-run-all --parallel build:cjs build:esm"
}
Once you have this system up and running, it should be straightforward to keep supporting ESM and CJS. Here’s a repository featuring the code in this article:
https://github.com/BretCameron/cjs-esm-monorepo-example
Related articles
You might also enjoy...
Automate Your Release Notes with AI
How to save time every week using GitLab, OpenAI, and Node.js
11 min read
How to Create a Super Minimal MDX Writing Experience
Learn to create a custom MDX experience so you can focus on writing without worrying about boilerplate or repetition
12 min read
I Fixed Error Handling in JavaScript
How to steal better strategies from Rust and Go—and enforce them with ESLint
14 min read