TypeScriptにおいてCommonJSとES Moduleの両方に対応するパッケージを作る

## はじめに

最近ではNode.jsのライブラリでも、[pure ESM](https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c)の考え方でES Moduleのみでしか利用できないものも出てきている。とはいえ、[npm-esm-vs-cjs](https://github.com/wooorm/npm-esm-vs-cjs/tree/main#data)というリポジトリのデータによると、以下のようにCommonJSのライブラリのほうが圧倒的に多く、ES Moduleのみはまだ10％程度しかない（みたい）。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/ae19b418-ba15-9975-2d4e-35533dfd7abb.png)

そこで、今回はライブラリを公開する際に、CommonJSとES Moduleの両方をサポートするようなプロジェクトの設定についてみていきたいと思う。

※前提として、今回は1つのTypeScriptファイルからCommonJS・ES Moduleの両方に対応するJavaScriptファイルを作成することを考える（ファイル拡張子を".mts"や".cts"にして、コンパイル後のファイル拡張子を変えることでCommonJS・ES Moduleに対応する方法ではない）。そのため、コンパイル後のコードとセットで"type"を指定するためのpackage.jsonが必要になる。ファイルの拡張子を変える事でデュアルパッケージにする方法は[Native ESM / 擬似 ESM Dual package （module:node12 以降）](https://zenn.dev/teppeis/articles/2021-10-typescript-45-esm#native-esm-%2F-%E6%93%AC%E4%BC%BC-esm-dual-package-%EF%BC%88module%3Anode12-%E4%BB%A5%E9%99%8D%EF%BC%89)などを参照。

## その前に

CommonJSとES Moduleの両方をサポートする、言わゆるデュアルパッケージの設定をする前に、既存の設定についてみておく。

今回は2023年7月25日時点で、pure ESMのライブラリとして公開されている[retry-axios](https://github.com/JustinBeckwith/retry-axios/tree/dc47e0f082766e56edf95caad4d618ab6858b63e)を題材にしてやってみたいと思う。

このライブラリのpackage.jsonの設定は、`"type": "module"`になっており、そのためTypeScriptをコンパイル後のJavaScriptは`***.js`となりES Moduleの環境でしか利用できない状態になる。

※ES Moduleのみでしか利用できないライブラリをCommonJSにコンパイルする環境のTypeScriptで利用すると、以下のようにエラーになる。
```console
$ yarn tsc -p .
yarn run v1.22.19
$ tsc -p .
srv/app.ts:3:29 - error TS1479: The current file is a CommonJS module whose imports will produce 'require' calls; however, the referenced file is an ECMAScript module and cannot be imported with 'require'. Consider writing a dynamic 'import("retry-axios")' call instead.
  To convert this file to an ECMAScript module, change its file extension to '.mts' or create a local package.json file with `{ "type": "module" }`.

3 import * as retryAxios from 'retry-axios';
                              ~~~~~~~~~~~~~


Found 1 error in srv/app.ts:3
```

## デュアルパッケージ（CommonJS・ES Moduleの両方をサポートするよう）に設定を変更する

[はじめに](#はじめに)でも少し触れたが、今回は1つのTypeScriptファイルをコンパイルして作成されるJavaScriptでCommonJS・ES Moduleの両方をサポートするようにする。そのため、コンパイル後のコードは、例えば以下のようなディレクトリ構成になる必要がある。
```
- package.json          // { "type": "module" }
- index.ts              
- tsconfig.json         // output to `module` directory
- tsconfig.cjs.json     // output to `cjs` directory
- cjs/
    - package.json      // { "type": "commonjs" }
    - index.js          // Node.js treat it as CommonJS module
- module/
    - package.json      // { "type": "module" }
    - index.js          // Node.js treat it as ESModule
```

これは[issue](https://github.com/microsoft/TypeScript/issues/49462)で議論されているように、コンパイル後のJavaScriptファイルの拡張子をいじれるようにする機能がコンパイラにはないため、拡張子ではないもの（今回だとpackage.json）でCommonJS・ES Moduleの両方をサポートするような設定にする必要がある。

今回は上記のようにそれぞれのディレクトリにpackage.jsonを配置することで、デュアルパッケージを実現してみようと思う。

まずは、CommonJSのJavaScriptを出力するための`tsconfig.cjs.json`を追加で作成する。基本的には、ES ModuleのJavaScriptを出力させるtsconfig.jsonの設定を踏襲して、モジュールのターゲットと出力先だけを変えればいいので、以下のようなシンプルな設定になるだろう。
```json:tsconfig.cjs.json
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "module": "commonjs",
    "outDir": "build/cjs",
  }
}
```

続いて、package.jsonの設定を変える。以下のようにすればいい。
```json:package.json
{
  ...
  "type": "module",
  "exports": {
    ".": {
      "import": {
        "types": "./build/src/index.d.ts",
        "default": "./build/src/index.js"
      },
      "require": {
        "types": "./build/cjs/src/index.d.ts",
        "default": "./build/cjs/src/index.js"
      },
      "default": "./build/cjs/src/index.js"
    }
  },
  "types": "./build/src/index.d.ts",
  "main": "./build/cjs/src/index.js",
  "module": "./build/src/index.js",
  ...
}
```

元の設定との差分は以下のスクショを参照。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/1dc97b9e-3307-c534-89f9-d7384232166f.png)

上記の設定について少し補足する。

- [main](https://docs.npmjs.com/cli/v7/configuring-npm/package-json#main)
古いバージョンのNode.jsのためのCJSフォールバックのための設定
- module
Node.jsでは採用されなかった機能だが、バンドラーでは使われているもので、ES Moduleのエントリーポイントを示すための設定。詳細については[esbuild](https://esbuild.github.io/api/#main-fields)などのドキュメントを参照。
- [types](https://www.typescriptlang.org/docs/handbook/declaration-files/publishing.html#including-declarations-in-your-npm-package)
パッケージにバンドルされた型定義ファイルを示すための設定
- [exports](https://nodejs.org/api/packages.html#exports)
条件に応じてエントリーポイントを切り替えるための設定。importであればimportが、requireであればrequireが利用される（[Conditional exports](https://nodejs.org/api/packages.html#conditional-exports)なども参照）。

このpackage.jsonの設定により、CommonJS・ES Moduleのそれぞれでエントリーポイントを分けることができるので、デュアルパッケージを実現できる。

続いて、コンパイル後のディレクトリ構成は以下になるが、それぞれにpackage.jsonを作成するための設定を行う。
```
$ tree . -I "node_modules"
.
├── build
│   ├── cjs
│   │   ├── package.json // <- これ
│   │   └── src
│   │       ├── index.d.ts
│   │       ├── index.js
│   │       └── index.js.map
│   ├── package.json // <- これ
│   └── src
│       ├── index.d.ts
│       ├── index.js
│       └── index.js.map
...
```

今回は[tsconfig-to-dual-package](https://github.com/azu/tsconfig-to-dual-package)を利用する。このツールは、デュアルパッケージ用のpackage.jsonをtsconfigのoutDirに追加してくれる。

あとは、コンパイルをするためのscriptsを定義して、コンパイルを実行すればいい。
```json:package.json
{
    "scripts": {
    ...
    "compile": "tsc -p . && tsc -p tsconfig.cjs.json && tsconfig-to-dual-package",
}
```

これでデュアルパッケージのための設定は完了になる。

※今回は[tsconfig-to-dual-package](https://github.com/azu/tsconfig-to-dual-package)を利用してCommonJS・ES Moduleのそれぞれにpackage.jsonを追加したが、単純に`{"type": "module"}`などのモジュール設定のみのpackage.jsonを追加するでも対応は可能。[stripe-node](https://github.com/stripe/stripe-node/blob/v12.14.0/package.json#L64)ではそのような方法をとっている。

※デュアルパッケージを実現する方法は、https://github.com/microsoft/TypeScript/issues/49462#issuecomment-1633279027 などを参照。今回みてきたようなpackage.jsonを利用する方法以外にも、babelを利用したりrollupを利用する方法もあるだろう。

- 参考：[package.json Exports, Imports, and Self-Referencing](https://www.typescriptlang.org/docs/handbook/esm-node.html#packagejson-exports-imports-and-self-referencing)
- 参考：[乱立したpackage.jsonのエントリーポイントフィールド](https://trap.jp/post/1666/)

## まとめとして

今回はCommonJS・ES Moduleの両方に対応するパッケージを作成するための方法として、package.jsonの`type`フィールドが異なるディレクトリを作成して、それを`exports`で公開する方法をみてきた。今回はpure ESMのパッケージをCommonJSにも対応させる、ということをやったが、できればNativeでES Moduleを利用する世界が来ると楽になるだろうなと感じた（どちらにも対応するデュアルパッケージを作成するという必要もなくなるので）。

※今回の対応をPRで出してみたが、それは以下。

https://github.com/JustinBeckwith/retry-axios/pull/219