white jigsaw puzzle illustration

Writing a CJS/ESM hybrid-package with TypeScript

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 and exports
  • 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 like jest and ts-ject to know how to handle and run our files.
  • "declaration": true – Tells tsc to output type declarations for our code, so that types can be imported.
  • "declarationDir": "lib/types" – We tell tsc 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

“module”: “CommonJS” is for CJS and “module”: “ESNext” is for ESM

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 importing our package, ./lib/esm/index.js should be used. In case of requireing, ./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.

Leave a comment

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.