Create npm package with CommonJS and ESM support in TypeScript


If you want to create an npm package and ensure it can be used by everyone, you’ll want it to support CommonJS (CJS) and ECMAScript Modules (ESM). Here’s how to build such a package using TypeScript.

CommonJS and ESM

When building JavaScript apps, you have two module systems to choose from: CommonJS and ECMAScript Modules. Despite the recent rise of ESM, CommonJS is still widely used, and not to mention default in Node.js. To make sure your npm package can be used by everyone, you’ll want to support both module systems.

Project setup

CommonJS and ESM are not compatible with each other. To support both, your package needs to contain two versions of the code, one for CommonJS and one for ESM. To support usage in TypeScript, you’ll also need to include type definitions. Since the shape of the exported API is the same, you can use the same TypeScript code for both CommonJS and ESM.

Here’s the global structure of the code that your package should produce that you’re after:

project
├── dist // package output
│   ├── cjs
│   │   └── CJS code
│   ├── esm
│   │   └── ESM code
│   └── types
│       └── TypeScript type definitions
└── src // source code
    └── TypeScript source

The following sections describe how to set up the project to produce such output.

Note: Check out the full source code of the sample project on GitHub.

TypeScript configuration

TypeScript can produce only one output at a time. To produce both CommonJS and ESM code, you need two different TypeScript configuration files (tsconfig.json). The good news is, that you can reuse common settings and only specify the distinct parts for CJS and ESM.

Start, by creating a base TypeScript configuration file with shared settings (tsconfig.base.json):

{
  "compilerOptions": {
    "lib": [
      "esnext"
    ],
    "declaration": true,
    "declarationDir": "./dist/types",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "moduleResolution": "node",
    "baseUrl": ".",
    "rootDir": "./src"
  },
  "include": [
    "src"
  ],
  "exclude": [
    "dist",
    "node_modules"
  ]
}

The file specifies, among other things, the location of the source code, folders to exclude, and the output directory for type definitions.

Next, create two separate configuration files for CommonJS and ESM.

tsconfig.cjs.json:

{
  "extends": "./tsconfig.base.json",
  "compilerOptions": {
    "module": "commonjs",
    "outDir": "./dist/cjs",
    "target": "ES2015"
  }
}

tsconfig.esm.json:

{
  "extends": "./tsconfig.base.json",
  "compilerOptions": {
    "module": "esnext",
    "outDir": "./dist/esm",
    "target": "esnext"
  }
}

Notice, how both files extend the base configuration and only specify the module system and the distinct output directory.

package.json configuration

After setting up TypeScript, continue with the package.json configuration. Here, you need to do a few things. You need to specify entry points for CJS and ESM consumers and define scripts to build the package in both formats.

Define entry points

Start, by defining entry points for CJS and ESM consumers.

{
  "name": "ts-cjs-esm",
  "version": "1.0.0",
  "main": "./dist/cjs/index.js",
  "module": "./dist/esm/index.mjs",
  "types": "./dist/types/index.d.ts",
  "files": [
    "dist"
  ]
  // ...
}

You’ve got the main entry point to refer to the CJS code, and the module entry point to ESM code. Both formats reuse the same TypeScript type definitions. Unfortunately, the main property overrules the module property in Node.js, so you need an extra way to specify how different consumers should load the library. You can do this using the exports property.

{
  "name": "ts-cjs-esm",
  "version": "1.0.0",
  "main": "./dist/cjs/index.js",
  "module": "./dist/esm/index.mjs",
  "types": "./dist/types/index.d.ts",
  "exports": {
    ".": {
      "require": "./dist/cjs/index.js",
      "import": "./dist/esm/index.mjs"
    }
  },
  "files": [
    "dist"
  ]
  // ...
}

Using the exports property, you can specify how different consumers should load the library. The require property points to the CJS code and the import property to the ESM code.

.mjs vs .js file extension

You might’ve noticed, that the ESM code has the .mjs extension while CJS uses .js. This is necessary to distinguish ESM from CJS code. If your package contains only ESM code, in the package.json file you can configure the type property to module to indicate that the package contains ESM code. This instructs Node.js to treat all files as ES modules. Unfortunately, in this case, that’s not possible, because the package contains both CJS and ESM code. This means, that you need to use the .mjs extension to designate ESM files. One more thing that complicates matters some more is, that TypeScript doesn’t allow you to specify the output file extension and always produces .js files. To work around this, you need to rename the output files, and imports after the build.

Define npm scripts

With the basic package setup in place, let’s continue with defining a few scripts to build the package in both formats.

{
  "name": "ts-cjs-esm",
  // ...
  "scripts": {
    "build:cjs": "tsc -p tsconfig.cjs.json",
    "build:esm": "tsc -p tsconfig.esm.json && npm run rename:esm",
    "build": "npm run build:cjs && npm run build:esm",
    "clean": "rimraf dist",
    "rename:esm": "/bin/zsh ./scripts/fix-mjs.sh",
    "prepack": "npm run clean && npm run build"
  }
  // ...
}

We start with two build scripts, one for CJS and one for ESM, each pointing to the respective TypeScript config file. As mentioned previously, TypeScript doesn’t allow you to specify the output file extension, so you need to rename the output files after the build. The rename:esm script runs a shell script that renames the .js files to .mjs and updates import references. For convenience, we also include a script to clean the output directory and build the package before publishing.

Install dependencies

In npm scripts, we’re using TypeScript and rimraf. Make sure to install them as dev dependencies:

npm install --save-dev typescript rimraf

Helper scripts

To support building ESM code, you need a helper script that renames the output files and updates import references. Create a shell script fix-mjs.sh in the scripts folder:

for file in ./dist/esm/*.js; do
  echo "Updating $file contents..."
  sed -i '' "s/\.js'/\.mjs'/g" "$file"
  echo "Renaming $file to ${file%.js}.mjs..."
  mv "$file" "${file%.js}.mjs"
done

This script iterates over all .js files in the dist/esm folder. For each file, it replaces .js' with .mjs' in the file contents, and then renames the file to have the .mjs extension.

Git- and npmignore

If you intend to store your source in a git repo, add a .gitignore file to your project, to avoid including unnecessary files in the repository:

dist
node_modules

Since you’re building a library, which you’ll likely distribute, you should also add a .npmignore file to exclude unnecessary files from the npm package:

scripts
src

Sample source

To test the setup, create simple TypeScript source files in the src folder.

myModule.ts:

export function myFunction() {
  return 'Hello World!';
}

index.ts:

export * from './myModule.js';

Notice, that when referring to the myModule file, you use the .js extension. This is required for the ESM build to work correctly. When building the ESM package, the helper script updates the extension to .mjs.

Build the package

To verify that the setup works, run the build script:

npm run build

If all is well, you should see the output in the dist folder:

dist
├── cjs
│   ├── index.js
│   └── myModule.js
├── esm
│   ├── index.mjs
│   └── myModule.mjs
└── types
    ├── index.d.ts
    └── myModule.d.ts

Summary

When building an npm package, consider supporting both CommonJS and ECMAScript Modules so that your package can be used by everyone. To support CJS and ESM, you need to produce two versions of the code, one for each module system. By configuring your project in a specific way, you can produce a package that supports both module systems and includes TypeScript type definitions. This way, you can provide a seamless experience for everyone using your package, regardless of the module system they use.

Others found also helpful: