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 the tsc 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 the d.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:

GitHub - BretCameron/cjs-esm-monorepo-example
https://github.com/BretCameron/cjs-esm-monorepo-example

© 2024 Bret Cameron