How to Build a TypeScript Project for Both CJS and ESM Targets

Written by isharafeev | Published 2023/02/03
Tech Story Tags: javascript | typescript | native-ecmascript-modules | ecmascript | build | nodejs | coding | tutorial

TLDRThis article will describe how we can build a TypeScript project for both CJS and ESM targets using a pure TypeScript compiler and native npm features. You can find an example project in my GitHub [repo]:https://github.com/sr-shifu/ts-build-octopus.via the TL;DR App

Before you start reading, please get familiar with the difference between CommonJS (CJS) and ECMAScript Modules (ESM). This article will describe how we can build a TypeScript project for both CJS and ESM targets using a pure TypeScript compiler and native npm features.

You can find an example project in my GitHub repo.

Motivation

This post is inspired by my beloved rxjs library — just take a look at how many tsconfig.json files they have there! Let’s try to build some minimal examples that will showcase how you can build your TypeScript (TS) project to both EcmaScript Modules and CommonJS targets. Of course, you can do the same nowadays using some fancy bundlers like Rollup, Webpack, Vite, etc — I bet there would be some new ones released by the time I finish writing my article — but I do it only for educational purposes (…and fun).

Imagine a situation when you want to have your library used by multiple projects in your organization — one is an old Node.js project built for CJS target, and another one is a modern and fancy browser application. Most likely, if you try to import the ESM bundle into the Node.js project, it won’t compile.

From words to business

Let’s create our package first. Run in your terminal:

npm init -y 
npm i -D typescript @types/node npm-run-all 
npx tsc --init

In generated tsconfig.json file (this would be our base file for different build targets) change outDir to point to build directory:

"outDir": "./build"

Now we can create our configuration for TS based on the build output format:

  • tsconfig.esm.json for ESM builds will generate output to esm folder

    {
      "extends": "./tsconfig.json",
      "compilerOptions": {
        "outDir": "./build/esm",
        "module": "esnext"
      }
    }
    

  • tsconfig.cjs.json for CJS builds will generate output to cjs folder

    {
      "extends": "./tsconfig.json",
      "compilerOptions": {
        "outDir": "./build/cjs",
        "module": "commonjs"
      }
    }
    
  • tsconfig.types.json for typings will generate output to types folder

    {
      "extends": "./tsconfig.json",
      "compilerOptions": {
        "outDir": "./build/types",
        "declaration": true,
        "emitDeclarationOnly": true
      }
    }
    

Let’s define our scripts to generate the build output. Go to package.json file and add these commands:

"compile": "tsc -b ./tsconfig.cjs.json ./tsconfig.esm.json ./tsconfig.types.json",
"build:clean": "rm -rf ./build", 
"build": "npm-run-all build:clean compile && && node ./scripts/prepare-package-json"

build:clean will simply clean up the target build directory before every new build. compile will use TypeScript compiler (tsc) to build our source (-b stands for build) based on the configuration we pass down it.

Theoretically, we can have more build formats to share (e.g., ESM5 to support older browsers). And finally, we will generate a special package.json file for our ESM build using our custom prepare-package-json script (more about this below). Now we can publish our package using npm publish.

But what if the point of publishing a library if there is no library? Let’s build something.

What will our library do?

Let’s create lib.ts file under src folder:

export async function run() {
  let type = "";
  const workerPath = "./worker.js";
  // require and __dirname are not supported in ESM
  // see: https://nodejs.org/api/esm.html#differences-between-es-modules-and-commonjs
  if (typeof require !== "undefined" && typeof __dirname !== "undefined") {
    type = "CJS";
    const { Worker, isMainThread } = require("worker_threads");
    if (isMainThread) {
      const worker = new Worker(__dirname + "/" + workerPath);
      worker.on("exit", (code: number) => {
        console.log(`Nodejs worker finished with code ${code}`);
      });
    }
  } else {
    type = "ESM";
    if (typeof Worker !== "undefined") {
      new Worker(workerPath);
    } else {
      console.log("Sorry, your runtime does not support Web Workers");
      await import(workerPath);
    }
  }
  console.log(`Completed ${type} build run.`);
}

The idea of this library would be to offload some expensive computational work to the worker instance. For Node.js, we will use worker thread implementation, while for browsers, we will use WebWorker API. As a fallback, we can lazy load the script into the main thread and execute it there.

For our worker code, we will use Fibonacci number calculation:

const maxLimit = 1_000_000;
let n1 = BigInt(0),
  n2 = BigInt(1),
  iteration = 0;
console.log("Starting fibonacci worker");
console.time("fibonacci");
while (++iteration <= maxLimit) {
  [n2, n1] = [n1 + n2, n2];
}

console.log("Fibonacci result: ", n1);
console.timeEnd("fibonacci");

This operation should take a while, so it’s worth extracting it into a separate thread (yes, JavaScript is not really a single-threaded) instead of blocking the main thread.

Let’s add some glue

Now we need to tell our consumers how to import our library without telling them exactly what path they need to import from. Here comes handy npm’s conditional exports feature:

  "exports": {
    "./*": {
      "types": "./build/types/*.d.ts",
      "require": "./build/cjs/*.js",
      "import": "./build/esm/*.js",
      "default": "./build/esm/*.js"
    }
  }

Or, for our use case, we can have them more specific (considering we output only single entry file to start working with our library):

 "exports": {
    ".": {
      "types": "./build/types/lib.d.ts",
      "require": "./build/cjs/lib.js",
      "import": "./build/esm/lib.js",
      "default": "./build/esm/lib.js"
    }
  }

How to read this? ./\* tells npm to resolve any path going after the package name (for example, import lib from 'my-fancy-lib/lib' will match /lib path), and . simply tells us to resolve the root import (import lib from 'my-fancy-lib').

The key (types, requre, import, default) defined in the hash object for this export will trigger based on the way the end package consumes this library:

  • import lib from 'my-fancy-lib/lib' (or import lib from 'my-fancy-lib') will resolve to <node\_modules>/my-fancy-lib/build/esm/lib.js
  • const lib = require('my-fancy-lib/lib') (or const lib = require('my-fancy-lib')) will resolve to <node\_modules>/my-fancy-lib/build/cjs/lib.js
  • default key is basically a fallback key if nothing matched the search. By the way, there are also a few other keys you can define - you can find all of them in the documentation.

Now the funny part. types key MUST be defined prior to all others, and the default key needs to go last. While I understand why default order is important (a common practice for fallback mechanisms), but I am not sure why it's important to have types first.

At the end of the day, it is simply a JSON file - runtime can read it first and then decide what priority to set.

You can also define conditional exports for TS typings using typesVersions:

 "typesVersions": {
    ">=3.1": { "*": ["ts3.1/*"] },
    ">=4.2": { "*": ["ts4.2/*"] }
  }

Hacky part

As I mentioned earlier, we need to execute some custom scripts at the end of the build. So why do we need it?

First of all, let’s look at the script:

const fs = require("fs");
const path = require("path");

const buildDir = "./build";
function createEsmModulePackageJson() {
  fs.readdir(buildDir, function (err, dirs) {
    if (err) {
      throw err;
    }
    dirs.forEach(function (dir) {
      if (dir === "esm") {
        var packageJsonFile = path.join(buildDir, dir, "/package.json");
        if (!fs.existsSync(packageJsonFile)) {
          fs.writeFile(
            packageJsonFile,
            new Uint8Array(Buffer.from('{"type": "module"}')),
            function (err) {
              if (err) {
                throw err;
              }
            }
          );
        }
      }
    });
  });
}

createEsmModulePackageJson();

So entire idea of this script is to generate separate package.json for ESM build (under build/esm directory) with the following content:

{"type": "module"}

This will tell the consumer build system that the underlying directory has modern EcmaScript modules. Otherwise, it will complain with:

SyntaxError: Unexpected token 'export'

Can we do better? Yes! npm has an implicit file extension convention to distinguish between ESM and CJS. All files with .mjs extension will be interpreted as ESM while .cjs - as CommonJS module. So instead of creating this hacky script, we can define "type": "module" in our root package.json and have CommonJS to require files using .cjs extension.

But I find an existing way is more user-friendly because consumers don’t need to worry about extensions; they can simply use this library as-is:

// for CommonJS 
const { run } = require("my-fancy-lib"); 
// for ESM 
import { run } from "my-fancy-lib";

Security considerations

There is a risk called dual package hazard:

When an application is using a package that provides both CommonJS and ES module sources, there is a risk of certain bugs if both versions of the package get loaded. This potential comes from the fact that the pkgInstance created by const pkgInstance = require('pkg') is not the same as the pkgInstance created by import pkgInstance from 'pkg' (or an alternative main path like 'pkg/module'). This is the "dual package hazard," where two versions of the same package can be loaded within the same runtime environment. While it is unlikely that an application or package would intentionally load both versions directly, it is common for an application to load one version while a dependency of the application loads the other version. This hazard can happen because Node.js supports intermixing CommonJS and ES modules, and can lead to unexpected behavior.

Final words

Having multiple variants of the same build which end users can consume without worrying about whether it’s compiled for their build target is always a nice user experience. In real projects, you are more likely to use UMD (Universal Module Definition) format that generates a single bundle for both worlds. However, sometimes it is useful to have fine granular builds — for example, when using module/nomodule pattern for loading scripts in the browser.


Also published here


Written by isharafeev | Sr Software Engineer. decade+ in the industry. Passionate about frontend, micro-frontends, serverless, and clean code.
Published by HackerNoon on 2023/02/03