6
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

ESモジュールとCommonJSと対応したTypeScriptパッケージの(たぶん)正しい作り方

Last updated at Posted at 2023-07-17

先日、JTC-utils という CommonJS と ESM に対応したデュアルパッケージを公開したのですが、とてつもなく苦労したのでここにメモを残しておくことにします。

デュアルパッケージに対応するという記事はいくつかありますが、この記事は次の条件の時に役に立つと思います。

  1. ソースコードは TypeScript で作りたい。
  2. CommonJS と ESモジュールの両方に対応したい。
  3. サブモジュール1を作りたい。(←ここ重要

2023/07/18 コメントを受け、一部訂正をいれています。
2023/07/19 さらに調べたところ、TypeScript の公式見解としては、たとえ型宣言ファイル(.d.ts)の内容が同じであっても、.cjs に対しては .d.cts を用意すべきとのことでしたので、その前提に基づき記述を全面的に見直しました。

何がそんなに難しいのか

TypeScript で作成したソースコードを CommonJS と ESモジュールにトランスパイルするため、ひとつのプロジェクト内に3つのソースコードが共存することになります。この時、次の条件が満たされる必要があります。

  • 開発時に JavaScript が TypeScript の型付きで認識されなければならない
  • ビルド時に TypeScript が JavaScript にトランスパイルされなければならない
  • 実行時に require を含むモジュールは CommonJS として認識されなければならない
  • 実行時に import を含むモジュールは ES モジュールとして認識されなければならない

普通に考えれば、tsconfig の設定に応じて .ts ファイルを ESモジュールとCommonJSそれぞれのソースコードとしてトランスパイルするだけに思えます。しかし、この簡単に見えることがそれぞれのツールの制約のため、とても難しいのです。

まず、最終形態としてひとつのパッケージ内にESモジュールとCommonJSのふたつのソースコードが含まれている状況を考えます。この時、foo.js というファイルは、ESモジュールでしょうか、それとも CommonJS モジュールでしょうか。

Node.js では次の仕様によりESモジュールとCommonJSを判定します。

  • 拡張子が .mjs となっているファイルはESモジュールである。
  • 拡張子が .cjs となっているファイルはCommonJSモジュールである。
  • package.json に "type": "module" と設定されているならば、拡張子が .js のファイルはESモジュールである。
  • package.json に type が未設定(あるいは"type": "commonjs" と設定されている)ならば、拡張子が .js のファイルは CommonJS モジュールである。

このことから、ESモジュールとCommonJSの両方に対応したパッケージにするには次のいずれかの組み合わせでなければならないことがわかります。

  • "type": "module" を設定し、ESモジュールは .js に CommonJS モジュールは .cjs にする
  • type を未設定に(あるいは"type": "commonjs"に設定)し、CommonJS モジュールは .js に ESモジュールは .mjs にする
  • ESモジュールは .mjs に CommonJS モジュールは .cjs にする

しかしながら、TypeScript のトランスパイラである tsc は .ts から .cjs や .mjs への変換をサポートしていません。tsc においては .ts は .js に、.mts は .mjs に、.cts は .cjs にという1対1変換のみです。

もうひとつ問題があります。従来、パッケージのエントリポイントは package.json の main が使われてきました。とはいえ、main には複数エントリを書く方法がないため、ESモジュールから呼ばれた場合とCommonJSモジュールから呼ばれた場合に異なるファイルを指し示すことができません。

Node.js ではこの目的のため package.json に exports という新たな設定項目を用意されているのですが、 残念なことに tsc は exports の types 指定を無視してしまうようです。 呼び出し側プロジェクトに"moduleResolution": "node"(or "node10")が指定されている場合、tsc は package.json の exports 指定が無視されてしまいます。

2023/07/13 Twitter にて指摘がありました。exports が無視されるか否かは、呼び出した側のプロジェクトの tsconfig に指定されている moduleResolution に依存するようです。
"moduleResolution": "node"(or "node10")がに指定されている場合、tsc は package.json の exports 指定を無視します。このため、type や typesVersions が指定されないと型が解決できないという現象が発生します。
"moduleResolution": "nodenext"(or "node16")が指定されている場合は、exports の指定が優先され type や typesVersions は無視されます。
この挙動はあくまで TypeScript がどう解釈するかという話であり、Node.js 側(すなわち実行時)は moduleResolution に関わらず exports が有効になることに注意してください。

TypeScript から ES モジュール向けと CommonJS 向けのソースコードを生成する

前節では2つの問題を挙げましたが、後者の問題に関しては解決済みです。あとは、.ts ファイルから .mjs や .cjs を生成できるようにするだけです。

最初に思いつくのは、単純に一括リネームしてしまう方法です。

ES モジュール向けと CommonJS 向けの プログラムをトランスパイルすること自体は、tsconfig を2つ用意して2回ビルドするだけです。例えば次のような設定ファイルが考えられます。

tsconfig.mjs.json
{
  "extends": "@tsconfig/recommended/tsconfig.json",
  "compilerOptions": {
    "module": "ESNext",
    "moduleResolution": "NodeNext",
    "outDir": "./mjs/",
    ...
  }
}
tsconfig.cjs.json
{
  "extends": "./tsconfig.mjs.json",
  "compilerOptions": {
    "module": "CommonJS",
    "moduleResolution": "Node",
    "outDir": "./cjs/"
  }
}

あとは単純に拡張子を .js から .mjs や .cjs にリネームするだけ……と言いたいところなのですが、この戦略はうまくいきません。

現在の TypeScript は開発方針として ECMAScript 標準とできるだけ矛盾させない(あくまで付加情報を追加するだけ)、という方針を取っています。その結果、TypeScript の import 文では原則として「トランスパイル後のファイル名を拡張子を省略せずに記述する」ことになりました

foo.ts
console.log("Hello world!")
bar.ts
import * from "./foo.js" // インポートするファイルは ./foo.ts

このため、拡張子の変更に合わせソースコード側の変換も必要となります。SourceMap の生成も考慮すると、(位置のずれが発生するため)生成後にファイル内の文字列置換を行うこともできません。

なお、「トランスパイル後のファイル名」を指定という制約は TypeScript 5.0 以降で緩和され tsconfig に "moduleResolution": "bundler""allowImportingTsExtensions": true を指定することで import のパス名に .ts のまま記述することが許されるようになりました。しかしながら、"bundler" という指定からもわかるように、tsc 以外の別のツールで変換されることが前提となり、tsc ではトランスパイルができなくなります(型ファイル生成は可能)。

これを踏まえると TypeScript のファイル名と import のパス指定は次の3パターンに制約されます。

  1. ファイルの拡張子には .ts を使い、import に指定するパスには .js を指定する(トランスパイルしたファイルの拡張子は .js)
  2. ファイルの拡張子には .ts を使い、import に指定するパスにも .ts を指定する(トランスパイルはできない)
  3. ファイルの拡張子には .mts/cts を使い、import に指定するパスには .mjs/cjs を指定する。(トランスパイルしたファイルの拡張子は .mjs/cjs)

今回の目的は ESモジュールと CommonJS の両方に対応することですから、3 の選択肢は除外されます。実はこれから説明するように例外的なパターンに対応できないため 1 の選択もおすすめできません。

ソースコードが TypeScript だからと言って、import されるのは TypeScript だけではありません。依存関係にあるライブラリから(パッケージ名でインポートするのではなく)特定のファイルを直接インポートする場合は .js 拡張子を指定するしかありません。この状況では import するファイルが .ts ファイル経由で .js に変換されたのか、元々 .js 拡張子のファイルを import したのか区別ができず、.ts ファイル由来の .js を .mjs/cjs に置き換えることが難しくなります。

結局 2 のパターンしか選択できないのですが、このパターンでは tsc によってトランスパイルができません。

この解決方法としていろいろ調べたのですが、型宣言ファイルは現状 tsc から生成できないため、tsc に mts/cts のファイルを出力させる必要があります。

スクリプトを書いて mts/cts ファイルを生成する。

tsc のプラグインで動的にファイルのパスを書き換えることが良かったのですが、残念ながら tsc には変換プラグイン機構が用意されていないようです。そこで、ソースコードをコピーし拡張子とimportパスを mts/cts に書き換え tsc でトランスパイルするスクリプトを用意します(少々長いのでリンク先を参照してください)。

build_ts.ts

あとは、scripts を定義して npm run build を実行するだけです。(実行には ts-node あるいは tsx を使います)

package.json
  "scripts": {
    "build": "tsx ./scripts/build_ts.ts",
    ...
  }

結果として以下のようなファイル群が出力されます。package.json の files にはこの2フォルダと package.json 自身を記載してください。

  • mjs: ESモジュール用フォルダ
    • lib: .mjs、.mjs.map、.d.mts、.d.mts.map の格納フォルダ
    • src: .mts の格納フォルダ
  • cjs: CommonJS 用フォルダ
    • lib: .cjs、.cjs.map、.d.cts、.d.cts.map の格納フォルダ
    • src: .cts の格納フォルダ

moduleResolution: Node16/Node10 の両方に対応したエントリポイントの書き方

まず、"moduleResolution": "nodenext"(or "node16")の場合の指定を考えます。この場合、TypeScriptもNode.js も exports を認識します。

package.json
  "exports": {
    "./submodule": {
      "require": {
        "types": "./cjs/lib/submodule/index.d.cts",
        "default": "./cjs/lib/submodule/index.cjs",
      },
      "default": {
        "types": "./mjs/lib/submodule/index.d.mts",
        "default": "./mjs/lib/submodule/index.mjs"
      }
    },
    ".": {
      "require": {
        "types": "./cjs/lib/index.d.cts",
        "default": "./cjs/lib/index.cjs"
      },
      "default": {
        "types": "./mjs/lib/index.d.mts",
        "default": "./mjs/lib/index.mjs"
      }
    }
  }

2023/07/18 コメントで指摘されているように、types の指定は require と import のそれぞれに設定する必要があるため記載を修正しています。

次に"moduleResolution": "node"(or "node10")の場合を考えます。この場合でも Node.js は exports を理解しますから、TypeScript にとっての設定だけが必要が必要です。これには従来からあった package.json の types と typesVersions が利用できます。

また、"node"(or "node10") がプロジェクトに指定されているということは読み込むモジュールとしてESモジュールは想定されていませんから CommonJS の設定だけが行われていればよいということになります。

サブモジュールを使わない場合は単に types に CommonJS 用の型宣言ファイルのパスを記述すれば大丈夫です。

package.json
  "types": "./cjs/lib/index.d.cts",

サブモジュールを使う場合には(本来の用途ではないのですが)typesVersions にそれぞれのパスを記述することができます。typesVersions は types よりも優先されるため、次のようにデフォルト設定が行われている場合には types は不要です。

package.json
  "typesVersions": {
    "*": {
        "submodule": ["./cjs/lib/submodule/index.d.cts"],
        "*": ["./cjs/lib/index.d.cts"]
    }
  }

実はこの他に CommonJS モジュールを package.json の main に設定し、ESモジュールを module に設定するという非標準の方法があります。WebpackやRollupなど主要なバンドラがこの非標準の設定をサポートしていますが、Node.js 自体はサポートしていませし、サブモジュールにも対応できません。

結局どういう構成になるのか

これでESモジュールとCommonJSと対応したTypeScriptパッケージが完成しました。具体的な設定は jtc-utils の実際の package.json を参考にしていただければと思います。

  1. import * from "main/sub" 形式の階層型モジュール構成

6
6
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?