How to build TypeScript to ESM and CommonJS
Tags: typescript, npm, esm, commonjs
Published: 2022-12-21 05:49:03

---
title: How to build TypeScript to ESM and CommonJS
published: true
date: 2022-12-20 01:22:25 UTC
tags: typescript,npm,esm,commonjs
canonical_url: https://thesametech.com/how-to-build-typescript-project/
---

![](https://cdn-images-1.medium.com/max/500/0*SChQBptGh6Xg--nM.png)

Before you start reading, please get familiar with the [difference](https://redfin.engineering/node-modules-at-war-why-commonjs-and-es-modules-cant-get-along-9617135eeca1) 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](https://github.com/sr-shifu/ts-build-octopus)_.

### Motivation

This post is inspired by my beloved [rxjs](https://rxjs.dev/) library — just take a look how many tsconfig.json files do they have [there](https://github.com/ReactiveX/rxjs/tree/8.x/src)! 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
```

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 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.`);
}

```

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](https://www.w3schools.com/html/html5_webworkers.asp). 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");
```

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](https://nodejs.org/api/packages.html#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 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](https://nodejs.org/api/packages.html#conditional-exports).

Now the funny part. `types` key **MUST** be defined prior all others (see [documentation](https://nodejs.org/api/packages.html#community-conditions-definitions)), 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](https://www.typescriptlang.org/docs/handbook/declaration-files/publishing.html#version-selection-with-typesversions):

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

### 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();
```

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 consumer build system that underlying directory has modern EcmaScript modules. Otherwise, it will complain with:

```
SyntaxError: Unexpected token 'export'
```

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";
```

### Security considerations

There is a risk called [dual package hazard](https://nodejs.org/api/packages.html#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](https://philipwalton.com/articles/deploying-es2015-code-in-production-today/) for loading scripts in the browser.

* * *

_Originally published at_ [_https://thesametech.com_](https://thesametech.com/how-to-build-typescript-project/) _on December 20, 2022._

_You can also_ [_follow me on Twitter_](https://twitter.com/mastershifu89)_,_ [_subscribe to my Telegram channel_](https://t.me/the_same_tech) _and_ [_connect on LinkedIn_](https://www.linkedin.com/in/isharafeev/) _to get notifications about new posts!_