DEV Community

Ildar Sharafeev
Ildar Sharafeev

Posted on • Originally published at thesametech.com on

How to build TypeScript to ESM and CommonJS

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 TypeScript project to both CJS and ESM targets using pure TypeScript compiler and native npm features.

You can find example project in my GitHub repo.

Motivation

This post is inspired by my beloved rxjs library — just take a look how many tsconfig.json files do they have there! Let’s try to build some minimal example 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 released by the time I finish writing my article — but I do it only for educational purposes (…and fun).

Imagine situation when you want to have your library used by multiple projects in your organization — one is old Node.js project built for CJS target, another one is modern and fancy browser application. Most likely, if you try to import ESM bundle into 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
Enter fullscreen mode Exit fullscreen mode

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

"outDir": "./build"
Enter fullscreen mode Exit fullscreen mode

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" 
   } 
}
Enter fullscreen mode Exit fullscreen mode
  • tsconfig.cjs.json for CJS builds will generate output to cjs folder
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "outDir": "./build/cjs",
    "module": "commonjs"
  }
}
Enter fullscreen mode Exit fullscreen mode
  • tsconfig.types.json for typings will generate output to types folder
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "outDir": "./build/types",
    "declaration": true,
    "emitDeclarationOnly": true
  }
}
Enter fullscreen mode Exit fullscreen mode

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

build:clean will simply clean up target build directory before every new build. compile will use TS 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 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 library if there is no library? Let’s build something.

What our library will 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.`);
}

Enter fullscreen mode Exit fullscreen mode

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 fallback, we can lazy load script into 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");
Enter fullscreen mode Exit fullscreen mode

This operation should take a while, so it’s worth to extract it into 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"
    }
  }
Enter fullscreen mode Exit fullscreen mode

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

How to read this? ./\* tells npm to resolve any path going after package name (for example, import lib from 'my-fancy-lib/lib' will match /lib path), and . simply tells 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 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 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 all others (see documentation), and default key needs to go last. While I understand why default order is important (common practise 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/*"] }
  }
Enter fullscreen mode Exit fullscreen mode

Hacky part

As I mentioned earier, we need to execute some custom script 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();
Enter fullscreen mode Exit fullscreen mode

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

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

SyntaxError: Unexpected token 'export'
Enter fullscreen mode Exit fullscreen mode

Can we do better? Yes! npm has 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 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";
Enter fullscreen mode Exit fullscreen mode

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 user can consume without worrying about whether it’s compiled for their build target is always nice user experience. In real projects, you are more likely to use UMD (Universal Module Definition) format that generated 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.


Originally published at https://thesametech.com on December 20, 2022.

You can also follow me on Twitter, subscribe to my Telegram channel and connect on LinkedIn to get notifications about new posts!

Top comments (0)