TypeScript で"moduleResolution": "Node"は使わないほうがいい
はじめに
タイトルは若干煽りですが、TS 5.0 でBundler
という設定値が追加されたため、Node
を使う場面はほぼ無くなったと思います。
今回は Node.js と TypeScript のモジュール解決の仕組みについて、moduleResolution
というオプションの観点から解説します。
この記事を書くにあたって実際に動作確認は行っていますが、もしも間違っているところがあればご指摘いただけると幸いです。
なお、 Node.js LTS v18、TypeScript v5.0 時点での情報です。 今後のバージョンアップにて変更がある可能性があります。
TL;DR
"moduleResolution": "Node"
は使わないほうがいいおそらく求めているものは
Bundler
tsc をビルドツールとして使用している場合は
Node16
/NodeNext
がベストNode
を使う場合でもNode10
にしたほうが分かりやすい
Node.js におけるモジュール解決
moduleResolution
について解説する前に、Node.js におけるモジュール解決の仕組みについて解説します。
CommonJS と ES Modules
現在の Node.js では、2 つのモジュールシステムが使用可能です。
一つが古くからある CommonJS であり、もう一つが ES Modules です。
ES Modules は Node.js では比較的最近のバージョンからサポートされた機能で、Node.js v12 以降でデフォルトで使用可能です。
今回は 2 つのモジュールシステムの詳細な仕組みについては触れません。
さて、Node.js ではこの 2 つのモジュールシステムをpackage.json
のtype
というフィールドによって切り替えることができます。
type
がmodule
の場合は ES Modules が使用され、type
がcommonjs
の場合は CommonJS が使用されます。
なお、後方互換性のため未指定の場合は CommonJS が使用されます。
ちなみにこの設定はパッケージごとではなく、ディレクトリ毎に適用されます。
JS ファイルが import されたとき、その JS ファイルに最も近いpackage.json
のtype
が適用されます。
つまり一つのパッケージで CommonJS と ES Modules を混在させることも可能です。
また、.mjs
と.cjs
という拡張子を使用することでファイルごとの明確な指定も可能です。
main
と exports
package.json
ではmain
とexports
という 2 つのフィールドによってパッケージのエントリポイントを指定することができます。
main
フィールドは Node.js で古くからあるフィールドで、インポートした際のエントリポイントを指定します。これは CommonJS と ES Modules の両方で使用されます。
exports
フィールドは Node.js v12 以降で追加されたフィールドで、パッケージのエントリポイントを指定します。
main
との違いとして、exports
フィールドは CommonJS と ES Modules でのエントリポイントを指定することができます(Conditional Exports)。
また、サブパスを設定することもでき、package/subpath
のような指定をした際のエントリポイントを指定することも可能です。
2023/04/26 追記: main
フィールドは CommonJS でしか使われないというような記述をしていましたが、不正確だったため修正しました。@sapphi-redさん、ご指摘ありがとうございました。(該当 Issue)
{
"name": "package",
"exports": {
".": {
"require": "./dist/index.cjs",
"import": "./dist/index.mjs"
},
"./subpath": {
"require": "./dist/subpath.cjs",
"import": "./dist/subpath.mjs"
}
}
}
// CommonJS
// package/dist/index.cjs がimportされる
const { foo } = require("package");
// package/dist/subpath.cjs がimportされる
const { bar } = require("package/subpath");
// ES Modules
// package/dist/index.mjs がimportされる
import { foo } from "package";
// package/dist/subpath.mjs がimportされる
import { bar } from "package/subpath";
なお、exports
フィールドがある場合はパッケージ内の明示されたエントリポイント以外からの import はできなくなります。
また、main
とexports
の両方がある場合はexports
が優先されます。
後方互換性のためにもmain
は残しておき、exports
を追加するという形での導入が良いでしょう。
ちなみに Webpack などのバンドラではmodule
というフィールドがサポートされており、main
を CommonJS、module
を ES Modules として扱っていましたが、
Node.js ではmodule
というフィールドはサポートされていません。
バンドラもexports
フィールドをサポートしているので、現在ではmodule
フィールドよりもexports
フィールドを使用するほうがいいでしょう。
TypeScript におけるモジュール解決
TypeScript ではモジュール解決の方法をtsconfig.json
のmoduleResolution
というオプションによって指定することができます。
現在設定可能な値は以下の 6 つです。
Classic
Node
/Node10
Node16
/NodeNext
Bundler
Node
とNode10
は同じ意味で、Node16
とNodeNext
も同じ意味です。
ただしNodeNext
に関しては今後 Node.js に新たなモジュール解決の仕組みが追加された際には変更になる可能性があります。
デフォルト値はmodule
の値によって変わり、module
もtarget
の値によって変わるため、明確にしたい場合はmoduleResolution
を直接指定したほうがいいでしょう。
Classic
Classic
は TypeScript 1.5 以前のモジュール解決の仕組みです。ドキュメントでも詳しい記載がなく、現在ではほぼ使用されていません。
設定することもないでしょう。
Node
/ Node10
Node
/ Node10
は Node.js 12 以前のモジュール解決の挙動を再現します。
つまりモジュールシステムはtype
の値に関わらず CommonJS が使用され、main
フィールドがエントリポイントとして使用されます。
一点注意なのが、Node.js 12 以前の挙動のため、外部パッケージのtype
やexports
も無視されるということです。
そのためmain
とexports
で異なるファイルパスを指定していたり、exports
で CommonJS と ES Modules で異なるエントリポイントを指定していたりすると、
実行時する際に使われるファイルと TypeScript で参照しているファイル(とそれに付随する型定義ファイル)が異なる場合があるため注意が必要です。
{
"name": "package",
"main": "./index.cjs",
"exports": {
".": {
"require": "./commonjs/index.cjs",
"import": "./esm/index.mjs"
}
}
}
// CommonJS
// exportsは無視され、package/index.cjs がimportされる
// TypeScriptなので使われるのはpackage/index.d.cts
import { foo } from "package";
// ES Modules
// typesやexportsは無視されるため、package/index.cjs がimportされる
// TypeScriptなので使われるのはpackage/index.d.cts
import { foo } from "package";
これらの挙動を見ると分かるように、Node
は実際には Node.js の挙動を再現するのではなく、古い Node.js の挙動を再現しているだけです。
これは後方互換性のためにNode
という名前が付けられているだけなのではないかと思います。
古い Node.js の挙動であるということを分かりやすくするためにも、
Node
という設定値よりもNode10
という設定値を使って、明示したほうがいいでしょう。
Node16
/ NodeNext
Node16
/ NodeNext
は現在の Node.js のモジュール解決の挙動を再現します。
つまりモジュールシステムはtype
の値によって CommonJS か ES Modules が使用され、exports
フィールドがエントリポイントとして使用されます。
注意点として Node.js の ES Modules では拡張子の補完やディレクトリパスを指定した際のindex.js
の補完が行われないため、
ES Modules を使用する場合はTypeScript でも import 先を.js
の拡張子を含めたパスで指定する必要があります。
これは TypeScript は tsc でのコンパイルにあたって拡張子の変換を行わないと明言しているためこのような仕様になっていると考えられます。
{
"name": "package",
"main": "./index.cjs",
"exports": {
".": {
"require": "./commonjs/index.cjs",
"import": "./esm/index.mjs"
}
}
}
// CommonJS
// exportsのrequireが使用されpackage/commonjs/index.cjs がimportされる
// TypeScriptなので使われるのはpackage/commonjs/index.d.cts
import { foo } from "package";
// ES Modules
// exportsのimportが使用されpackage/esm/index.mjs がimportされる
// TypeScriptなので使われるのはpackage/esm/index.d.mts
import { foo } from "package";
Bundler
Bundler
は Webpack などのバンドラで使用されるモジュール解決の挙動を再現します。
つまりtype
の値に関わらず、CommonJS における拡張子の補完やディレクトリパスを指定した際のindex.js
の補完は行われますが、
ES Modules のようにexports
はimport
が使用されます。また、exports
が存在しない場合はmain
にフォールバックします(ES Modules に無い挙動)。
{
"name": "package",
"main": "./index.cjs",
"exports": {
".": {
"require": "./commonjs/index.cjs",
"import": "./esm/index.mjs"
}
}
}
// CommonJS
// exportsのimportが使用されpackage/esm/index.mjs がimportされる
// TypeScriptなので使われるのはpackage/esm/index.d.mts
import { foo } from "package";
// ES Modules
// exportsのimportが使用されpackage/esm/index.mjs がimportされる
// TypeScriptなので使われるのはpackage/esm/index.d.mts
import { foo } from "package";
ちなみに多くのバンドラでサポートされているmodule
フィールドに関してはサポートしていません。
また注意点として、tsc ではこの挙動を満たす JavaScript ファイルを出力することができません(拡張子の補完と ES Modules の共存はできず、tsc は import 先の書き換えを行わないため)。
設定値の通り、バンドラを使用するのが前提であるため、Bundler
に設定したい場合は Webpack や ESBuild などのバンドラを使用しましょう。
結局どの設定を使えばいいのか
moduleResolution
の設定値はビルド環境によっても異なってきますが、いくつかの場合を考えてみました。
Web アプリを開発していて、Node.js で実行する予定がない場合
Web アプリを開発している場合はビルドツールはほとんどバンドラを使用するため、Bundler
を使用するのが良いでしょう。
CommonJS のような拡張子の補完やディレクトリパスを指定した際のindex.js
の補完は行われますし、ES Modules 対応のパッケージではexports
の値が使用されます。
ただし、バンドラの設定によっては TypeScript とバンドラでファイルの解決先が異なる場合があるため一度確認しておいた方が良いでしょう。
Node.js 向けのアプリを開発していて、ビルドツールでバンドラを使用している場合
バンドラを使用している場合は上記と同様にBundler
を使用するのが良いでしょう。
ただし先程と同様で ES Modules で実行するのに拡張子が補完されていないと実行時にエラーとなってしまうため、バンドラの設定を見直す必要があります。
Node.js 向けのアプリを開発していて、ビルドツールでバンドラを使用していない場合(tsc でコンパイルしている場合)
この場合はNode16
を使用するのが良いでしょう。Bundler
の方が使い勝手はいいですが、tsc 単体では拡張子の補完ができないため、
ES Modules で実行するのに拡張子が補完されていないと実行時にエラーとなってしまいます。
CommonJS で実行する際もpackage.json
のtype
やexports
を確認してくれるため、Node
よりもより現在の Node.js に近い挙動を再現できます。
Node.js 向けのライブラリを開発していて、tsc でコンパイルしている場合
この場合もNode16
を使用するのが良いと思います。
しかしながら、tsc 単体では ES Module と CommonJS の 2 つの JavaScript ファイルの出力が簡単にはできないため、unbuild や microbundle などのツールを使用したほうが良いでしょう。
まとめ
moduleResolution
の各設定値を確認すると、Node
を使用する場面がほぼ無いことが分かります。Node.js 12 以前の古い環境をサポートする必要がない限りはNode16
を使用するのが良いでしょう。
今までは ES Modules を使用している場面でも拡張子の補完が欲しいためにNode
を指定していたことが多々ありましたが、TS 5.0 でのBundler
の追加によって、Node
を使用する必要がなくなりました。
かつては CommonJS と ES Modules が混在し、Node.js でも TypeScript でもモジュールシステム周りの混乱が生じましたが、現在では比較的その混乱は落ち着いてように思えます。
TypeScript と Node.js のモジュール周りのバグの可能性を無くすためにも、Node
は使用せず、Node16
かBundler
を使用するのが良いでしょう。