visit
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 .
This post is inspired by my beloved rxjs library — just take a look at how many tsconfig.json files they have ! 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).
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"
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
.
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: //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.`);
}
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.
"exports": {
"./*": {
"types": "./build/types/*.d.ts",
"require": "./build/cjs/*.js",
"import": "./build/esm/*.js",
"default": "./build/esm/*.js"
}
}
"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 .
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.
"typesVersions": {
">=3.1": { "*": ["ts3.1/*"] },
">=4.2": { "*": ["ts4.2/*"] }
}
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"}
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.
// for CommonJS
const { run } = require("my-fancy-lib");
// for ESM
import { run } from "my-fancy-lib";
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.