The Problem
ES Modules are increasingly adopted in many environments including modern browsers and nodejs. Besides a more flexible and concise syntax, there are many more reasons to use them. As there are many projects built on CJS and some more obstacles when using ESM (e.g. nodejs using CJS by default), it is still a good idea to create a hybrid-package. This post describes how to write a hybrid-package that is compatible with both CJS and ESM.
To emphasize a little of the cumbersomeness that comes with such a migration, take a look on required steps to use ESM in nodejs:
- declare the project as a ES Module with
"type": "module"
, - rename files to
.mjs
to interpret them as ESM or - use dynamic imports to avoid CJS / ESM compatibility problems
As a package author, we want our users not to worry much about these things and have a consistent and usable (developing) experience. We want to support CJS and ESM scenarios so that it doesn’t matter if that package is used in a CJS or ESM project. To ensure easier maintainability and readability in our code, we want to use TypeScript.
package.json properties
To get things started we need some preliminaries cleared first. The package.json
of our package can contain some fields to help us create a hybrid package.
- we can tell nodejs where to find files for CJS and ESM import styles using the properties
main
,module
andexports
- we can specify if our package itself is a CJS or ES modules with the
type
property. This also controls how JS files are interpreted. - we could also rename files to
.cjs
and.mjs
Configuring TypeScript for ESM and CJS
Now first I want to exclude the third option if we want to support browser environments, because browsers do not care about file extensions. This approach will work in nodejs but might not in browsers.
Then we setup our base project. We will use npm here, but use any package tool of your choice. Create a folder awesome-package
and initialize npm in that with npm init
.
All our source code will be maintained in a src
directory, so create that too. For demonstration purposes you can create an index.ts
file with any TypeScript code.
export function main() { console.log("Hello, World!"); }
Our TypeScript files are useless for nodejs and browsers without transpilation. We install TypeScript as a dev dependency for now, but you can use any transpiler that your projects require.
npm install -D typescript
Every TypeScript project requires a tsconfig.json
file, so let’s set that up as well
//tsconfig.json { "compilerOptions": { ... "module": "ESNext", "esModuleInterop": true, "declaration": true, "declarationDir": "lib/types", "baseUrl": "." }, "include": ["src/**/*", "test/**/*"] }
The above configuration is not complete and should be filled according to your needs. I will focus on those properties that are relevant for creating a hybrid package.
-
"module": "ESNext"
– This tells the TypeScript compiler (tsc
) that we want a ES Module as an output. This is our “standard” configuration. It will also be used for tools likejest
andts-ject
to know how to handle and run our files. "declaration": true
– Tellstsc
to output type declarations for our code, so that types can be imported."declarationDir": "lib/types"
– We telltsc
to generate type declarations into a specific directory. This will apply for CJS and ESM since types have no effect on the type of the package.
Now that our base configuration is setup, we will create two more configurations for CJS and ESM respectively. You can name them accordingly:
//tsconfig.esm.json { "extends": "./tsconfig.json", "compilerOptions": { "outDir": "lib/esm" }, "exclude": ["node_modules", "**/*.spec.ts", "**/*.test.ts"] }
//tsconfig.cjs.json { "extends": "./tsconfig.json", "compilerOptions": { "module": "CommonJS", "outDir": "lib/cjs" }, "exclude": ["node_modules", "**/*.spec.ts", "**/*.test.ts"] }
Can you spot the difference? "module": "CommonJS"
tells tsc
to output our package in CJS with "outDir": "lib/cjs"
pointing its’ output into the lib/cjs
directory. In conclusion, our package will have type declarations in lib/types
, ESM JavaScript files in lib/esm
and CJS in lib/cjs
The source files can be transpiled by running tsc and passing it the corresponding configuration file. Simply add the following scripts to your package.json
:
// package.json { ... "scripts": { "build": "tsc -p tsconfig.esm.json && tsc -p tsconfig.cjs.json" } }
Exporting files
Now that we have generated ESM and CJS files, we need to tell npm
which files to include when installing / releasing the package. As mentioned in the beginning we can use some of the properties in our package.json
to declare just that.
// package.json { ... "main": "lib/cjs/index.js", "module": "lib/esm/index.js", "exports": { "import": "./lib/esm/index.js", "require": "./lib/cjs/index.js" }, "types": "lib/types/index.d.ts", "files": ["lib"], ... }
The most important point here is the way we declare conditional exports to tell consumers where to find the entry points to our package. As we have both CJS and ESM, when import
ing our package, ./lib/esm/index.js
should be used. In case of require
ing, ./lib/cjs/index.js
should be used instead. The main
and module
properties also define entry points for our package. However, in fact "module"
is ignored completely by nodejs (see: Dual CommonJS ES Module packages). Because we don’t know the user environment, we still include both in case e.g. bundlers make use of it. Continuing, we tell consumers where to find our type declarations with "types": "lib/types/index.d.ts"
. Remember, that’s the configured path from our tsconfig.json
. Finally, with "files": ["lib"]
we tell npm to publish the lib folder to the npm registry so that users can install it from remote.
Interoperating CJS and ESM in nodejs
Now if we want to use our package in both CJS and ESM scenarios in nodejs, we will encounter a problem. By default, our package is treated as CJS because it hasn’t been configuerd otherwise. Right now, we cannot import
our package, because nodejs treats it as CJS, which doesn’t handle the import
keyword. Luckily, we can use the type property to tell nodejs if our package should be treated as CJS or ESM. If we add "type": "module"
to package.json
we can use imports again. But now we can’t use require
anymore because our package is no CJS anymore. Yikes. One workaround would be to use dynamic import with the import("awesome-package")
syntax. However, this is process asynchronous and we might need to await
it.
If we read carefully through the type property documentation, there is a mechanic of a package.json file as their nearest parent. We can exploit this to provide separate package.json
files for our exported CJS and ESM packages. lib/esm
gets the package.json
file with a "type": "module"
appended. Likewise, lib/cjs
gets the package.json
with a "type": "commonjs"
. I have written a command line tool (hybrid-package-json) to simplify this process. You can add it as a postbuild hook:
// package.json { ... "scripts": { "build": "tsc -p tsconfig.esm.json && tsc -p tsconfig.cjs.json", "postbuild": "npx hybrid-package-json --esm-target=lib/esm --cjs-target=lib/cjs" } }
Now we can correctly use our package as CJS and ESM.
Conclusion
ESM will be the new standard and is widely adopted. Still, there are some pitfalls when using nodejs. We have written a package that correctly exports as CJS and ESM for consumers so that their environment doesn’t matter. Unfortunately, until nodejs interprets exported files according to their condition, we have to provide a package.json for each type. Until then, you can use the hybrid-package-json
script to simplify your workflow.