Join me in the post as I enhance an NPM package to support both ESM and CJS (CommonJS) consumers through the power of TSC (TypeScript Compiler).
It is a common challenge for NPM package maintainers to have their package support both ESM and CJS consumers. I was intrigued by the question of how to achieve this without creating a complex build process - fortunately, nowadays there are great tools and features which help achieve this goal quite easily.
By the end of this post I will convert one of my packages to support this hybrid mode. The package I chose is my @pedalboard/hook package, which perhaps is not the best candidate for hybrid mode, but it makes a good case study. As a bonus we will also get TypeScript declarations for that package ;)
Setting the requirements first
Before I start diving into the code, it is always a good idea to define the desired end result, or what will be considered as “done”:
- The package will have a “build” process which will create 2 artifacts: one for ESM and the other for CJS.
- The package will also contain it’s TSD (TypeScript declarations) so that anyone who consumes it can benefit from it.
- Consumers of this package will get the suitable artifact according to the method of obtaining the package seamlessly. No additional configuration is required from their side.
We’re all set? Let’s start -
Background
My hooks package currently holds a single hook - use-pagination-hook. This hook is being used by a component from my components package, which is called “Pagination” (surprising, I know).
The Pagination component imports the hook, as you do in React, using ESM import.
My hooks package currently exposes it’s root index.js
file which is an import-barrel file, or in other words, a file which groups all the different modules the package exports.
The exposure configuration is done in the package’s package.json file, in the “main” field:
{
"name": "@pedalboard/hooks",
"version": "0.1.2",
"description": "A set of well-crafted React hooks",
"main": "index.js",
"author": "Matti Bar-Zeev",
"license": "MIT",
...
This allows me to import the hooks like so:
import {usePagination} from '@pedalboard/hooks';
I would obviously like to keep it that way.
The “build” process
I would like to create a “build” process which will take the “simple” JS files I have, do nothing with them but deploy them into a “dist” directory.
The tool I would like to use for this is TSC (TypeScript Compiler). While some may choose rollup.js or other bundles to do this work, I think using TSC is a great choice here since I know that in the future I would like to support TypeScript for this package, so why not?
I start with installing TypeScript:
yarn add -D typescript
Cool. now I will create the tsconfig.json
file with some default configurations for TS.
Here is my initial configuration:
{
"compilerOptions": {
"module": "ES2020",
"noImplicitAny": true,
"removeComments": true,
"preserveConstEnums": true,
"sourceMap": true,
"allowJs": true,
"outDir": "dist/esm",
"moduleResolution": "Node",
"declaration": true,
"declarationDir": "dist/types"
},
"files": ["index.js"],
"include": ["src/**/*"],
"exclude": ["node_modules", "**/*.test.js"]
}
The important thing to notice here is the module
field, which is set to ES2020
. This means that the final artifact will be in ESM format.
The entry point for the compiler will be index.js
directory and I include all the files under src/**/*
so they will be included in the program.
The output dir is set to dist/esm
, so that the final artifacts will be generated there.
I also configure that I would like type declaration to be generated under the dist/types
directory.
Another important thing to mention is that I’m using allowJs
to true since I’m not using TS yet. I’m just “compiling” ordinary JS files ATM.
Now that we have that in place, let’s try to run “tsc” and see what happens. I expect that new directories will be created and under them my package’s source code in ESM format…
Yes, sure enough when I run “yarn tsc” a new directory is created and in it there are the ESM JS files. Here is the content of that directory:
As you can see, all the source files are in the src directory and I also have the “types” directory which holds all the type declarations which will eventually be bundled with this package.
(Don’t forget to add the “dist” folder to your .gitignore so it won’t get tracked by Git.)
Can we use our package as it is now? no, not yet.
The package.json file still holds configuration which is not aligned with our new approach. Let’s make some changes to comply with it
Main
Our package.json
defines which is the main file it exposes. “The main field is a module ID that is the primary entry point to your program”. This is the default file which is returned when the package is required or imported.
It is currently set to the index.js
file which is under the root directory of the package, but I will change it to point to the index.js
file which is under dist/esm directory:
"main": "./dist/esm/index.js",
Types
The next thing I would like to do is define where the package’s types reside, so that anyone using this package will benefit from them, either by good intellisense or by type safety.
I do this with the “types” field in the package.json
file, and set it to index.d.ts which in under dist/types directory:
"types": "./dist/types/index.d.ts",
Build
This whole thing introduces another step that needs to be executed before the package is published, and that’s the “build” step.
In this build step I will run TSC so the artifacts mentioned above could be generated. I will first add this script to my package.json
file:
"scripts": {
...
"build": "tsc"
},
And now when running yarn build
TSC will run and do its magic.
So far…
Although I didn’t write a single line in TS, I have a package which goes through TS compilation in order to produce and ESM compliant code and export its types. If I go to the code using the hook, I will see that the types are according to the TSD files I bundle in the hooks package, when hovering over:
(alias) usePagination({ totalPages, initialCursor, onChange, }?: {
totalPages: any;
initialCursor: any;
onChange: any;
}): {
totalPages: any;
cursor: any;
goNext: () => void;
goPrev: () => void;
setCursor: (value: any) => void;
Remember - I'm not using TS in my source code yet, so the types are the default generic ones.
Moving on.
Producing an additional CommonJS artifact
So far our build process produces ESM module artifacts and Types, but if you remember our initial requirements, I wanted to also produce CommonJS (CJS) module artifacts. How do we go about it?
As I see it, the best and most elegant way to solve this is to create 2 different tsconfig.json
files - one for ESM and one for CJS.
First I will change the name of my tsconfig.json
file to tsconfig.esm.json
. After doing that, TSC can no longer reach this file without me helping it, so I need to instruct it where to look for this file.
I do this in my “build” script like so:
"build": "tsc --project tsconfig.esm.json"
Running my build step now works as it used to.
Creating a TSC configuration file for CJS
I first start with completely copy/pasting the ESM configuration and changing just what matters. Later on I will do this more elegantly by extending a base configuration, for better maintenance.
My new file name is tsconfig.cjs.json
and it’s content is:
{
"compilerOptions": {
"module": "CommonJS",
"noImplicitAny": true,
"removeComments": true,
"preserveConstEnums": true,
"sourceMap": true,
"allowJs": true,
"outDir": "dist/cjs",
"moduleResolution": "Node",
"declaration": true,
"declarationDir": "dist/types"
},
"files": ["index.js"],
"include": ["src/**/*"],
"exclude": ["node_modules", "**/*.test.js"]
}
Notice the different values in the module
and outDir
fields.
Now I can add another process to the package's build
script, which will run TSC with the CJS configuration as well. Here is my revised “build” script
"build": "tsc --project tsconfig.esm.json & tsc --project tsconfig.cjs.json"
I’ve used the single
&
here to allow both processes to run in parallel, for time optimization.
Running yarn build
now creates another directory under dist
which has the artifacts for CJS.
Awesome! But having duplicated configurations is not that great. I will create a tsconfig.base.json
which looks like this:
{
"compilerOptions": {
"noImplicitAny": true,
"removeComments": true,
"preserveConstEnums": true,
"sourceMap": true,
"allowJs": true,
"moduleResolution": "Node",
"declaration": true,
}
}
And then extend it in both ESM and CJS configurations, for example, here is the configuration for ESM:
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "ES2020",
"outDir": "dist/esm",
"declarationDir": "dist/types"
},
"files": ["index.js"],
"include": ["src/**/*"],
"exclude": ["node_modules", "**/*.test.js"]
}
Much better, though I hate the fact that all path locations must be declared in the inheriting configurations due to tsconfig limitations.
Making the package support both ESM and CJS seamlessly
So we have a “dist” directory which has artifacts for both ESM and CJS, but how do we expose them so that consumers using CJS will get the suitable artifact and those using ESM will get their suitable artifact?
We have conditional exports or “exports” for that. The “exports” field in the package.json
allows you to configure how your package should act if required or imported (among other options).
Following the docs here are the changes done in the package’s package.json
file:
"exports": {
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.js",
"default": "./dist/esm/index.js"
},
When consumed with “import” the entry point is the ESM index.js file. When consumed with “require”, the CJS entry point is used. And I added the “default” which is ESM as well.
Wrapping up
And there we have it!
I’ve taken TSC and used it as a simple bundler which can produce both ESM and CJS artifacts from my package’s source code. I then allowed my package to be consumed by either ESM or CJS code with the help of the NPM’s “exports” feature.
I also have type declaration which comes with my package, and if that’s not enough, my package is TS supported (when the right time will come to migrate it).
I’m very pleased with the result :) but as always, if you have any ideas on how to make this better or any other technique, be sure to share with the rest of us!
Hey! If you liked what you've just read check out @mattibarzeev on Twitter 🍻
Top comments (2)
Great article, I updated my exports following your hybrid approach and I think you could improve the tsc build a bit more. It looks like you're producing the Types twice because your
tsconfig.base.json
, which is then used by both CJS/ESM config, is emitting the Types (dts) for both. To avoid emitting them twice, you could removedeclaration: true
from the base config and instead add it in your build script so that it's emitting them only onceanother approach could be to add
"declaration": true
to only 1 of the tsconfig CJS or ESM, however I'm not sure if it's possible in your project because it looks like you're sometime building only CJS and other times only ESM, sotsc --emitDeclarationOnly
might the best optionHave a nice day :)
It was very helpful in supporting CJS and ESM in my NPM package (twined-components). Thank you.