Publishing ESM and CommonJS packages with Typescript
Tags: typescript, npm, node, webdev
Published: 2024-03-25 19:22:38

ECMAScript modules (esm) is the official way to handle imports and exports in Javascript, but CommonJS (cjs) has been part of the ecosystem do so long that support for it still feels necessary.

In this post I'll show my method for publishing an npm package that exports both esm and cjs compatible code.

The code is available on stackblitz.

_Note: it is better to view the code on stackblitz as the embedding shows some errors that aren't real._

{% stackblitz https://stackblitz.com/edit/tsc-as-cjs-esm?file=src%2Findex.ts&view=editor %}


## src

For example purposes, the `src/` directory is more complicated than it needs to be. There is an `index.ts` file, which exports modules from the `arithmetic/` directory.


## tools

Besides Typescript, this process also relies on [`tsc-alias`](https://www.npmjs.com/package/tsc-alias) and [`@alcalzone/esm2cjs`](https://github.com/AlCalzone/esm2cjs).

The first is used to add `.js` extensions for imports, which isn't necessary in all contexts, but doesn't hurt.

The second compiles esm projects to cjs.

So right away, you can see there is quite a lot happening in the build process — tsc, tsc-alias, and esm2cjs.

This isn't ideal for large projects, but for small projects the build time is minimal.


## configs

This is the minimal tsconfig config needed:

```json
{
  "compilerOptions": {
    "declaration": true,
    "declarationDir": "dist/types",
    "esModuleInterop": true,
    "lib": ["ES2021"],
    "module": "ESNext",
    "moduleResolution": "Node",
    "outDir": "dist/esm",
    "target": "ES2021"
  },
  "tsc-alias": {
    "resolveFullPaths": true,
    "verbose": true
  },
  "include": ["src"],
  "exclude": ["node_modules", "test"]
}
```

Note the config for `tsc-alias`.


## package.json

The most complicated part is the `package.json`:

```json
{
  "name": "tsc-as-cjs-esm",
  "version": "0.1.0",
  "main": "./dist/cjs/index.js",
  "module": "./dist/esm/index.js",
  "types": "./dist/types/index.d.ts",
  "files": ["dist"],
  "exports": {
    "./package.json": "./package.json",
    ".": {
      "types": "./dist/types/index.d.ts",
      "require": "./dist/cjs/index.js",
      "import": "./dist/esm/index.js"
    }
  },
  "scripts": {
    "start": "tsc --watch",
    "build": "tsc && tsc-alias -p tsconfig.json",
    "postbuild": "esm2cjs --in dist/esm --out dist/cjs -l error"
  },
  "devDependencies": {
    "@alcalzone/esm2cjs": "^1.1.2",
    "tsc-alias": "^1.8.8",
    "typescript": "^5.2.2"
  }
}
```

The `exports` block lets node know where to look when importing or requiring a module.


## building

You can build the package by running `npm run build`. This causes:
- Typescript to compile the project
- tsc-alias to add file extensions
- esm2cjs to create a cjs package from the above output

This creates a `dist/` directory that looks like this:

```bash
dist
├── cjs
├── esm
└── types
```

Inside `esm/` and `cjs/` each, there is a `package.json`.

```json
// dist/esm/package.json
{
    "type": "module"
}
```

and

```json
// dist/cjs/package.json 
{
    "type": "commonjs"
}
```

After that, running `npm pack` creates a tarball and copies the root `package.json` to the package.


## testing

Create a package that uses esm modules with a package.json:

```json
{
  "name": "test",
  "main": "index.js",
  "type": "module"
}
```

Note the `"type": "module"`,

Move the tarball into the repo (not necessary) and install the it using `npm i tsc-as-cjs-esm-0.1.0.tgz`.

Then in `index.js`, you can do:

```js
import { add } from "tsc-as-cjs-esm";

console.log(add(1, 2));
```

If you remove the `"type": "module"` line, you'll get an error and have to use cjs syntax:

```js
const math = require("tsc-as-cjs-esm");
const add = math.add;

console.log(add(1, 2));
```