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.