Hybrid NPM package through TypeScript Compiler (TSC)

Matti Bar-Zeev - Feb 11 '22 - - Dev Community

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",
    ...
Enter fullscreen mode Exit fullscreen mode

This allows me to import the hooks like so:

import {usePagination} from '@pedalboard/hooks';
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"]
}
Enter fullscreen mode Exit fullscreen mode

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:

Image description

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",
Enter fullscreen mode Exit fullscreen mode

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",
Enter fullscreen mode Exit fullscreen mode

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"
},
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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"]
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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.

Image description

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,
   }
}
Enter fullscreen mode Exit fullscreen mode

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"]
}
Enter fullscreen mode Exit fullscreen mode

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"
   },
Enter fullscreen mode Exit fullscreen mode

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 🍻

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .