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 library for both CJS and ESM targets using 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 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 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 toesm
folder
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./build/esm",
"module": "esnext"
}
}
tsconfig.cjs.json
for CJS builds will generate output tocjs
folder
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./build/cjs",
"module": "commonjs"
}
}
tsconfig.types.json
for typings will generate output totypes
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., ES5 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. 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 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'
(orimport lib from 'my-fancy-lib'
) will resolve to<node_modules>/my-fancy-lib/build/esm/lib.js
const lib = require('my-fancy-lib/lib'
) (orconst 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/*"] }
}
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 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:
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 generated 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.
Discover more from The Same Tech
Subscribe to get the latest posts to your email.
nice post, think is using tsc is the best way to build typescript libraries for 3r party usage.
Just one comment. you don’t need the last bit hacky part in your main pacakge.json yuu can use `main` , `module` and `type` fields to indicate the entry point for each type of files
ie:
“main”: “./.dist/cjs/index.js”,
“module”: “./.dist/esm/index.js”,
“types”: “./.dist/types/index.d.ts”,
You are right, thank you!
I will update my post!
Man, you saved me! Thanks!
Thanks! This article was very helpful! Saved my day.
I’m a senior engineer working with Javascript for past 10 years but combining ESM and CJS in a library package was a challenge.
In the end I found out I must add “typesVersions” to my package.json to make everything work.
https://stackoverflow.com/a/69791012
Thanks for helpful article. I guess the “types” property in package.json should actually be “./build/types/index.d.ts” (the example didn’t work for me with just “index.d.ts”, at least in pnpm workspace).