モジュール - 理論

JavaScriptにおけるスクリプトとモジュール

JavaScriptがブラウザでのみ実行されていた初期の頃には、モジュールはありませんでしたが、HTMLで複数のscriptタグを使用することで、WebページのJavaScriptを複数のファイルに分割することが可能でした。

html
<html>
<head>
<script src="a.js"></script>
<script src="b.js"></script>
</head>
<body></body>
</html>

このアプローチには、特にWebページが大きくなり複雑になるにつれて、いくつかの欠点がありました。特に、同じページに読み込まれたすべてのスクリプトは、同じスコープ(適切に「グローバルスコープ」と呼ばれます)を共有するため、スクリプトは互いの変数や関数を上書きしないように非常に注意する必要がありました。

ファイルに独自のスコープを与えながら、他のファイルにコードの一部を公開する方法を提供するシステムは、「モジュールシステム」と呼ぶことができます。(モジュールシステム内の各ファイルが「モジュール」と呼ばれることは明白に聞こえるかもしれませんが、この用語は、グローバルスコープでモジュールシステムの外部で実行されるスクリプトファイルと対比するために使用されることがよくあります。)

多くのモジュールシステムがあり、TypeScriptはいくつかの出力をサポートしていますが、このドキュメントでは、今日最も重要な2つのシステム、ECMAScriptモジュール(ESM)とCommonJS(CJS)に焦点を当てます。

ECMAScriptモジュール(ESM)は、言語に組み込まれたモジュールシステムで、最新のブラウザとNode.js v12以降でサポートされています。専用のimportおよびexport構文を使用します。

js
// a.js
export default "Hello from a.js";
js
// b.js
import a from "./a.js";
console.log(a); // 'Hello from a.js'

CommonJS(CJS)は、ESMが言語仕様の一部になる前に、もともとNode.jsで出荷されたモジュールシステムです。現在もESMと並んでNode.jsでサポートされています。exportsrequireという名前のプレーンなJavaScriptオブジェクトと関数を使用します。

js
// a.js
exports.message = "Hello from a.js";
js
// b.js
const a = require("./a");
console.log(a.message); // 'Hello from a.js'

したがって、TypeScriptがファイルがCommonJSまたはECMAScriptモジュールであることを検出すると、そのファイルが独自のスコープを持つと想定することから始まります。しかし、それ以外に、コンパイラの仕事は少し複雑になります。

モジュールに関するTypeScriptの仕事

TypeScriptコンパイラの主な目標は、コンパイル時にランタイムエラーをキャッチすることで、特定の種類のランタイムエラーを防ぐことです。モジュールが関与しているかどうかにかかわらず、コンパイラは、コードの意図されたランタイム環境(たとえば、使用可能なグローバル変数)について知る必要があります。モジュールが関与している場合、コンパイラがジョブを実行するために答える必要がある追加の質問がいくつかあります。入力コードの数行を例として使用して、分析に必要なすべての情報について考えてみましょう。

ts
import sayHello from "greetings";
sayHello("world");

このファイルをチェックするために、コンパイラはsayHelloの型(1つの文字列引数を受け入れることができる関数ですか?)を知る必要があり、さらに多くの追加の質問が開かれます。

  1. モジュールシステムはこのTypeScriptファイルを直接ロードしますか、それともこのTypeScriptファイルから生成したJavaScriptファイルを(私または別のコンパイラが)ロードしますか?
  2. モジュールシステムがロードするファイル名とディスク上の場所を考えると、モジュールシステムはどのような種類のモジュールを見つけることを期待しますか?
  3. 出力JavaScriptが出力される場合、このファイルに存在するモジュール構文は出力コードでどのように変換されますか?
  4. モジュールシステムは"greetings"で指定されたモジュールを見つけるためにどこを探しますか?検索は成功しますか?
  5. その検索で解決されたファイルはどのような種類のモジュールですか?
  6. モジュールシステムは、(2)で検出されたモジュールの種類が、(5)で検出されたモジュールの種類を(3)で決定された構文で参照することを許可していますか?
  7. "greetings"モジュールが分析されたら、そのモジュールのどの部分がsayHelloにバインドされますか?

これらの質問はすべて、出力JavaScript(または場合によっては生のTypeScript)を最終的に消費して、モジュール読み込み動作を指示するホスト(通常はランタイム(Node.jsなど)またはバンドラー(Webpackなど))の特性に依存することに注意してください。

ECMAScript仕様は、ESMインポートとエクスポートが相互にどのようにリンクするかを定義していますが、モジュール解決として知られる(4)のファイル検索がどのように発生するかは指定しておらず、CommonJSのような他のモジュールシステムについては何も述べていません。そのため、ランタイムとバンドラー、特にESMとCJSの両方をサポートしたい場合は、独自のルールを設計する自由度が高くなります。その結果、TypeScriptが上記の質問にどのように答えるべきかは、コードを実行する予定の場所によって大きく異なる可能性があります。単一の正解はないため、コンパイラは構成オプションを通じてルールを指示する必要があります。

もう一つ覚えておくべき重要な点は、TypeScriptがこれらの問題を考える際、ほとんどの場合、入力のTypeScript(またはJavaScript!)ファイルではなく、出力のJavaScriptファイルを基準に考えるということです。今日では、一部のランタイムやバンドラーがTypeScriptファイルの直接読み込みをサポートしており、その場合は入力ファイルと出力ファイルを分けて考える意味がありません。このドキュメントのほとんどは、TypeScriptファイルがJavaScriptファイルにコンパイルされ、それがランタイムのモジュールシステムによって読み込まれるケースについて議論しています。これらのケースを検討することは、コンパイラのオプションと動作を理解するために不可欠です。まずはそこから始め、esbuild、Bun、その他のTypeScriptファーストのランタイムとバンドラーについて考える際には、単純化するのが簡単です。したがって、現時点では、モジュールに関するTypeScriptの役割を出力ファイルの観点から要約することができます。

ホストのルールを十分に理解し、

  1. ファイルを有効な出力モジュール形式にコンパイルし、
  2. それらの出力におけるインポートが正常に解決されるようにし、
  3. インポートされた名前に割り当てるべきを知ること。

ホストとは誰か?

先に進む前に、「ホスト」という用語について認識を一致させておくことが重要です。この用語は頻繁に出てくるからです。以前は、「モジュールのロード動作を指示するために最終的に出力コードを使用するシステム」と定義しました。言い換えれば、TypeScriptのモジュール解析がモデル化しようとする、TypeScriptの外部のシステムのことです。

  • 出力コード(tscまたはサードパーティのトランスパイラによって生成されたもの)がNode.jsのようなランタイムで直接実行される場合、そのランタイムがホストになります。
  • ランタイムがTypeScriptファイルを直接使用するため、「出力コード」がない場合でも、ランタイムがホストになります。
  • バンドラーがTypeScriptの入力または出力を消費してバンドルを生成する場合、バンドラーがホストになります。これは、バンドラーが元のインポート/requireのセットを見て、それらが参照していたファイルを調べ、元のインポートとrequireが消去または認識できないほど変換された新しいファイルまたはファイルのセットを生成するためです。(そのバンドル自体がモジュールを構成している可能性があり、それを実行するランタイムがそのホストになりますが、TypeScriptはバンドラー後の処理については何も知りません。)
  • 別のトランスパイラ、オプティマイザー、またはフォーマッターがTypeScriptの出力で実行される場合、それらはTypeScriptが関与するホストではありません。ただし、それらが出力に含まれるインポートとエクスポートをそのままにしている場合に限ります。
  • Webブラウザでモジュールをロードする場合、TypeScriptがモデル化する必要のある動作は、実際にはWebサーバーとブラウザで実行されているモジュールシステムの間で分割されています。ブラウザのJavaScriptエンジン(またはRequireJSのようなスクリプトベースのモジュールロードフレームワーク)が、受け入れられるモジュール形式を制御し、Webサーバーは、あるモジュールが別のモジュールをロードするリクエストをトリガーしたときに送信するファイルを決定します。
  • TypeScriptコンパイラ自体は、他のホストをモデル化しようとする以外にモジュールに関連する動作を提供しないため、ホストではありません。

モジュールの出力形式

どのプロジェクトでも、最初に答える必要のあるモジュールに関する質問は、ホストがどのような種類のモジュールを期待しているかということです。これにより、TypeScriptは各ファイルの出力形式を一致させることができます。場合によっては、ホストがサポートしているモジュールが1種類しかないことがあります。たとえば、ブラウザではESM、Node.js v11以前ではCJSなどです。Node.js v12以降ではCJSとESモジュールの両方を受け入れますが、ファイル拡張子とpackage.jsonファイルを使用して各ファイルの形式を決定し、ファイルの内容が期待される形式と一致しない場合はエラーをスローします。

moduleコンパイラオプションは、この情報をコンパイラに提供します。その主な目的は、コンパイル中に生成されるJavaScriptのモジュール形式を制御することですが、各ファイルのモジュール種類の検出方法、異なるモジュール種類が相互にインポートすることを許可する方法、import.metaやトップレベルのawaitなどの機能が利用可能かどうかをコンパイラに知らせる役割も果たします。したがって、TypeScriptプロジェクトでnoEmitを使用している場合でも、moduleに適切な設定を選択することが重要です。前に述べたように、コンパイラはインポートの型チェック(およびIntelliSenseの提供)を行うために、モジュールシステムを正確に理解する必要があります。プロジェクトに適したmodule設定の選択については、コンパイラオプションの選択を参照してください。

利用可能なmodule設定は次のとおりです。

  • node16:Node.js v16+のモジュールシステムを反映しており、ESモジュールとCJSモジュールを特定の相互運用性と検出ルールとともに並行してサポートします。
  • nodenext:現在はnode16と同一ですが、Node.jsのモジュールシステムが進化するにつれて、最新のNode.jsバージョンを反映するように変動します。
  • es2015:JavaScriptモジュールに関するES2015言語仕様を反映しています(importexportを初めて言語に導入したバージョン)。
  • es2020es2015import.metaexport * as ns from "mod"のサポートを追加します。
  • es2022es2020にトップレベルのawaitのサポートを追加します。
  • esnext:現在はes2022と同一ですが、最新のECMAScript仕様と、今後の仕様バージョンに含まれることが期待されるモジュール関連のStage 3+提案を反映して変動します。
  • commonjssystemamd、およびumd:それぞれ、指定されたモジュールシステム内のすべてを出力し、すべてがそのモジュールシステムに正常にインポートできると想定します。これらは新しいプロジェクトには推奨されなくなっており、このドキュメントでは詳細には説明しません。

Node.jsのモジュール形式検出と相互運用性に関するルールにより、tscによって出力されたすべてのファイルがそれぞれESMまたはCJSである場合でも、Node.jsで実行されるプロジェクトにmoduleesnextまたはcommonjsとして指定することは正しくありません。Node.jsで実行することを意図したプロジェクトに正しいmodule設定は、node16nodenextのみです。すべてのESM Node.jsプロジェクトで出力されたJavaScriptは、esnextnodenextを使用してコンパイルされた間では同じように見えるかもしれませんが、型チェックが異なる場合があります。詳細については、nodenextに関するリファレンスセクションを参照してください。

モジュール形式の検出

Node.jsはESモジュールとCJSモジュールの両方を理解しますが、各ファイルの形式は、ファイル拡張子と、ファイルのディレクトリとすべての祖先ディレクトリの検索で見つかった最初のpackage.jsonファイルのtypeフィールドによって決定されます。

  • .mjsファイルと.cjsファイルは、それぞれ常にESモジュールとCJSモジュールとして解釈されます。
  • .jsファイルは、最も近いpackage.jsonファイルに"module"の値を持つtypeフィールドが含まれている場合、ESモジュールとして解釈されます。package.jsonファイルがない場合、またはtypeフィールドが存在しないか、他の値を持っている場合、.jsファイルはCJSモジュールとして解釈されます。

ファイルがこれらのルールによってESモジュールであると判断された場合、Node.jsは評価中にCommonJSのmoduleオブジェクトとrequireオブジェクトをファイルのスコープに挿入しません。そのため、それらを使用しようとすると、ファイルがクラッシュします。逆に、ファイルがCJSモジュールであると判断された場合、ファイル内のimportおよびexport宣言は構文エラーを引き起こし、クラッシュします。

moduleコンパイラオプションがnode16またはnodenextに設定されている場合、TypeScriptは同じアルゴリズムをプロジェクトの入力ファイルに適用して、対応する出力ファイルのモジュール種類を決定します。--module nodenextを使用するサンプルプロジェクトで、モジュール形式がどのように検出されるかを見てみましょう。

入力ファイル名 内容 出力ファイル名 モジュール種類 理由
/package.json {}
/main.mts /main.mjs ESM ファイル拡張子
/utils.cts /utils.cjs CJS ファイル拡張子
/example.ts /example.js CJS package.json"type": "module"がない
/node_modules/pkg/package.json { "type": "module" }
/node_modules/pkg/index.d.ts ESM package.json"type": "module"がある
/node_modules/pkg/index.d.cts CJS ファイル拡張子

入力ファイルの拡張子が .mts または .cts の場合、TypeScript はそのファイルをそれぞれ ES モジュールまたは CJS モジュールとして扱うことを認識します。これは、Node.js が出力 .mjs ファイルを ES モジュールとして、出力 .cjs ファイルを CJS モジュールとして扱うためです。入力ファイルの拡張子が .ts の場合、TypeScript は最も近い package.json ファイルを参照してモジュール形式を決定する必要があります。これは、Node.js が出力 .js ファイルを検出したときに実行する動作と同じです。(pkg 依存関係内の .d.cts および .d.ts 宣言ファイルにも同じ規則が適用されることに注意してください。これらはこのコンパイルの一部として出力ファイルを生成しませんが、.d.ts ファイルの存在は、対応する .js ファイルの存在を *示唆* します。おそらく、pkg ライブラリの作成者が自身で入力 .ts ファイルに対して tsc を実行したときに作成されたものでしょう。Node.js は、その .js 拡張子と /node_modules/pkg/package.json 内の "type": "module" フィールドの存在により、ES モジュールとして解釈する必要があります。宣言ファイルについては、後のセクションで詳しく説明します。)

入力ファイルの検出されたモジュール形式は、TypeScript が各出力ファイルで Node.js が期待する出力構文を確実に出力するために使用されます。TypeScript が /example.jsimport および export ステートメントで出力した場合、Node.js はファイルを解析するときにクラッシュします。TypeScript が /main.mjsrequire 呼び出しで出力した場合、Node.js は評価中にクラッシュします。出力だけでなく、モジュール形式は、型チェックとモジュール解決の規則を決定するためにも使用されます。これについては、次のセクションで説明します。

--module node16 および --module nodenext での TypeScript の動作は、完全に Node.js の動作に動機付けられていることをもう一度述べておきます。TypeScript の目標は、コンパイル時に潜在的なランタイムエラーをキャッチすることであるため、ランタイムで何が起こるかを正確にモデル化する必要があります。このモジュール種類の検出に関するかなり複雑な一連の規則は、Node.js で実行されるコードをチェックするために *必要* ですが、Node.js 以外のホストに適用すると、過度に厳格であるか、単に不正確になる可能性があります。

入力モジュール構文

入力ソースファイルで確認される *入力* モジュール構文は、JS ファイルに出力される出力モジュール構文とはやや切り離されていることに注意することが重要です。つまり、ESM インポートを使用するファイル

ts
import { sayHello } from "greetings";
sayHello("world");

は、ESM 形式でそのまま出力される場合もあれば、CommonJS として出力される場合もあります。

ts
Object.defineProperty(exports, "__esModule", { value: true });
const greetings_1 = require("greetings");
(0, greetings_1.sayHello)("world");

module コンパイラオプション(および module オプションが複数の種類のモジュールをサポートしている場合は、適用可能なモジュール形式検出規則)によって異なります。一般に、これは入力ファイルの内容を見るだけでは、それが ES モジュールなのか CJS モジュールなのかを判断するのに十分ではないことを意味します。

今日、ほとんどの TypeScript ファイルは、出力形式に関係なく、ESM 構文(import および export ステートメント)を使用して作成されています。これは主に、ESM が広くサポートされるようになるまでの長い道のりの遺産です。ECMAScript モジュールは 2015 年に標準化され、2017 年までにほとんどのブラウザでサポートされ、2019 年に Node.js v12 で採用されました。この期間の多くで、ESM が JavaScript モジュールの未来であることは明らかでしたが、それを消費できるランタイムはほとんどありませんでした。Babel のようなツールを使用すると、JavaScript を ESM で作成し、Node.js またはブラウザで使用できる別のモジュール形式にダウングレードすることができました。TypeScript もこれに追随し、ES モジュール構文のサポートを追加し、1.5 リリースで、元の CommonJS に触発された import fs = require("fs") 構文の使用を控えめに推奨しました。

この「ESM を作成し、何でも出力する」戦略の利点は、TypeScript が標準の JavaScript 構文を使用できるため、作成エクスペリエンスが新規参入者にとって使い慣れたものになり、(理論的には)プロジェクトが将来 ESM 出力をターゲットにするのを簡単にできることでした。3 つの重大な欠点があり、これは ESM と CJS モジュールが Node.js で共存および相互運用できるようになって初めて明らかになりました。

  1. Node.js での ESM/CJS の相互運用がどのように機能するかについての初期の仮定は間違っていることが判明し、今日では、相互運用性のルールは Node.js とバンドラーの間で異なります。その結果、TypeScript のモジュールの構成空間は大きくなっています。
  2. 入力ファイル内の構文がすべて ESM のように見える場合、作成者またはコードレビュー担当者は、ファイルがランタイムでどのような種類のモジュールであるかを見失いがちです。また、Node.js の相互運用性ルールのため、各ファイルがどのような種類のモジュールであるかが非常に重要になりました。
  3. 入力ファイルが ESM で記述されている場合、型宣言出力 (.d.ts ファイル) 内の構文も ESM のように見えます。ただし、対応する JavaScript ファイルは任意のモジュール形式で出力されている可能性があるため、TypeScript は型宣言の内容を見るだけでは、ファイルがどのような種類のモジュールであるかを判断できません。繰り返しますが、ESM/CJS の相互運用性の性質上、TypeScript は正しい型を提供し、クラッシュするインポートを防ぐために、すべてがどのような種類のモジュールであるかを *知る* 必要があるのです。

TypeScript 5.0 では、TypeScript 作成者が import および export ステートメントがどのように出力されるかを正確に把握できるように、verbatimModuleSyntax という新しいコンパイラオプションが導入されました。有効にすると、このフラグは、入力ファイルのインポートとエクスポートを出力前に最小限の変換で済む形式で記述することを要求します。したがって、ファイルが ESM として出力される場合、インポートとエクスポートは ESM 構文で記述する必要があります。ファイルが CJS として出力される場合は、CommonJS に触発された TypeScript 構文 (import fs = require("fs") および export = {}) で記述する必要があります。この設定は、ほとんどが ESM を使用するが、いくつかの CJS ファイルを持つ Node.js プロジェクトで特にお勧めします。現在 CJS をターゲットにしているが、将来 ESM をターゲットにしたいと考えているプロジェクトにはお勧めしません。

ESM と CJS の相互運用性

ES モジュールは CommonJS モジュールを import できますか?もしそうなら、デフォルトのインポートは exports または exports.default にリンクしますか?CommonJS モジュールは ES モジュールを require できますか?CommonJS は ECMAScript 仕様の一部ではないため、ランタイム、バンドラー、およびトランスパイラーは、ESM が 2015 年に標準化されて以来、これらの質問に対する独自の回答を自由に作成してきました。そのため、相互運用性ルールに関する標準セットは存在しません。今日、ほとんどのランタイムとバンドラーは、大きく 3 つのカテゴリのいずれかに分類されます。

  1. ESM のみ。ブラウザエンジンなどの一部のランタイムは、実際には言語の一部であるもの、つまり ECMAScript モジュールのみをサポートしています。
  2. バンドラーライク。主要な JavaScript エンジンが ES モジュールを実行できるようになる前に、Babel は開発者がそれらを CommonJS にトランスパイルすることで記述できるようにしました。これらの ESM がトランスパイルされた CJS ファイルが、手書きの CJS ファイルと対話する方法は、バンドラーとトランスパイラーの事実上の標準となった寛容な相互運用性ルールを示唆していました。
  3. Node.js。Node.js では、CommonJS モジュールは ES モジュールを同期的に(require を使用して)読み込むことはできません。それらは動的な import() 呼び出しで非同期的にのみ読み込むことができます。ES モジュールは CJS モジュールをデフォルトでインポートできます。これは常に exports にバインドされます。(これは、__esModule を使用した Babel ライクな CJS 出力のデフォルトのインポートが、Node.js と一部のバンドラーの間で異なる動作をすることを意味します。)

TypeScript は、どのルールセットを仮定する必要があるかを知ることで、(特に default)インポートで正しい型を提供し、ランタイムでクラッシュするインポートでエラーを出す必要があります。module コンパイラオプションが node16 または nodenext に設定されている場合、Node.js のルールが強制されます。その他すべての module 設定は、esModuleInterop オプションと組み合わせて、TypeScript でバンドラーライクな相互運用性をもたらします。(--module esnext を使用すると、CommonJS モジュールを *記述* するのを防ぐことはできますが、依存関係としてそれらを *インポート* するのを防ぐことはできません。現在、ブラウザに直接コードを記述する場合に適しているように、ES モジュールが CommonJS モジュールをインポートするのを防ぐことができる TypeScript 設定はありません。)

モジュール指定子は変換されません

module コンパイラオプションは、入力ファイルのインポートとエクスポートを出力ファイル内の異なるモジュール形式に変換できますが、モジュール *指定子*(import する from 文字列、または require に渡す文字列)は常に記述されたとおりに出力されます。たとえば、次のような入力

ts
import { add } from "./math.mjs";
add(1, 2);

は、次のいずれかとして出力される場合があります

ts
import { add } from "./math.mjs";
add(1, 2);

または

ts
const math_1 = require("./math.mjs");
math_1.add(1, 2);

module コンパイラオプションによって異なりますが、モジュール指定子は常に "./math.mjs" になります。モジュール指定子の変換、置換、または書き換えを有効にするコンパイラオプションはありません。したがって、モジュール指定子は、コードのターゲットランタイムまたはバンドラーで機能する方法で記述する必要があり、それらの *出力* 相対指定子を理解するのが TypeScript の仕事です。モジュール指定子によって参照されるファイルを見つけるプロセスは、*モジュール解決* と呼ばれます。

モジュール解決

最初の例に戻って、これまでに学んだことを見ていきましょう。

ts
import sayHello from "greetings";
sayHello("world");

これまで、ホストのモジュールシステムと TypeScript の module コンパイラオプションがこのコードにどのように影響するかについて説明しました。入力構文が ESM のように見えることはわかっていますが、出力形式は module コンパイラオプション、場合によってはファイル拡張子、および package.json"type" フィールドに依存します。また、sayHello がバインドされるもの、さらにはインポートが許可されるかどうかは、このファイルとターゲットファイルのモジュール種類によって異なる可能性があることもわかっています。しかし、ターゲットファイルを *見つける* 方法についてはまだ説明していません。

モジュール解決はホスト定義

ECMAScript 仕様は import および export ステートメントを解析および解釈する方法を定義していますが、モジュール解決はホストに任されています。もしあなたが新しいホットな JavaScript ランタイムを作成する場合、次のようなモジュール解決スキームを自由に作成できます。

ts
import monkey from "🐒"; // Looks for './eats/bananas.js'
import cow from "🐄"; // Looks for './eats/grass.js'
import lion from "🦁"; // Looks for './eats/you.js'

それでも「標準に準拠したESM」を実装していると主張します。言うまでもなく、TypeScriptは、このランタイムのモジュール解決アルゴリズムに関する組み込みの知識なしに、monkeycowlionに割り当てる型を知ることはできません。 moduleがホストの期待されるモジュール形式をコンパイラに通知するのと同様に、moduleResolutionは、いくつかのカスタマイズオプションとともに、ホストがモジュール指定子をファイルに解決するために使用するアルゴリズムを指定します。これは、TypeScriptがemit中にimport指定子を変更しない理由も明確にします。import指定子とディスク上のファイル(もし存在するなら)の関係はホストによって定義され、TypeScriptはホストではないからです。

利用可能なmoduleResolutionオプションは次のとおりです。

  • classic: TypeScriptの最も古いモジュール解決モードで、残念ながらmodulecommonjsnode16、またはnodenext以外に設定されている場合のデフォルトです。おそらく、幅広いRequireJS構成に対して最善の解決策を提供するために作成されました。新しいプロジェクト(またはRequireJSや別のAMDモジュールローダーを使用していない古いプロジェクト)には使用すべきではなく、TypeScript 6.0で非推奨になる予定です。
  • node10: かつてはnodeとして知られていましたが、modulecommonjsに設定されている場合の残念なデフォルトです。Node.jsのv12より前のバージョンのかなり良いモデルであり、時にはほとんどのバンドラーがモジュール解決を行う方法の許容できる近似値です。node_modulesからパッケージを検索したり、ディレクトリのindex.jsファイルをロードしたり、相対モジュール指定子で.js拡張子を省略したりすることをサポートしています。ただし、Node.js v12ではESモジュールに対して異なるモジュール解決ルールが導入されたため、最新バージョンのNode.jsのモデルとしては非常に不適切です。新しいプロジェクトには使用すべきではありません。
  • node16: これは--module node16に対応し、そのmodule設定でデフォルトで設定されます。Node.js v12以降では、ESMとCJSの両方がサポートされており、それぞれ独自のモジュール解決アルゴリズムを使用しています。Node.jsでは、importステートメントと動的なimport()呼び出しのモジュール指定子は、ファイル拡張子や/index.jsサフィックスを省略することはできませんが、require呼び出しのモジュール指定子は省略できます。このモジュール解決モードは、--module node16によって制定されたモジュール形式検出ルールによって決定されるとおり、必要に応じてこの制限を理解し、適用します。(node16nodenextの場合、modulemoduleResolutionは密接に関連しています。一方をnode16またはnodenextに設定し、他方を別のものに設定すると、サポートされていない動作になり、将来エラーになる可能性があります。)
  • nodenext: 現在はnode16と同一で、これは--module nodenextに対応し、そのmodule設定でデフォルトで設定されます。Node.jsの新しいモジュール解決機能が追加されたときにサポートする、将来を見据えたモードになることを目的としています。
  • bundler: Node.js v12では、npmパッケージをインポートするための新しいモジュール解決機能(package.json"exports"フィールドと"imports"フィールド)が導入され、多くのバンドラーがESMインポートのより厳格なルールを採用せずにこれらの機能を採用しました。このモジュール解決モードは、バンドラーをターゲットとするコードの基本アルゴリズムを提供します。デフォルトでpackage.json"exports""imports"をサポートしていますが、それらを無視するように構成できます。moduleesnextに設定する必要があります。

TypeScriptはホストのモジュール解決を模倣しますが、型を使用します

モジュールに関するTypeScriptの仕事の3つの構成要素を思い出してください。

  1. ファイルを有効な出力モジュール形式にコンパイルする
  2. これらの出力のインポートが正常に解決されることを保証する
  3. インポートされた名前に割り当てるを知っている。

モジュール解決は、最後の2つを達成するために必要です。しかし、入力ファイルで作業する時間がほとんどの場合、(2)を忘れがちになる可能性があります。モジュール解決の重要な要素は、入力ファイルと同じモジュール指定子を含む出力ファイルのimportまたはrequire呼び出しが、実際にはランタイムで機能することを検証することです。複数のファイルを使用した新しい例を見てみましょう。

ts
// @Filename: math.ts
export function add(a: number, b: number) {
return a + b;
}
// @Filename: main.ts
import { add } from "./math";
add(1, 2);

"./math"からのimportを見ると、「これは1つのTypeScriptファイルが別のファイルを指す方法だ。コンパイラはaddに型を割り当てるためにこの(拡張子のない)パスをたどる」と考えるのが妥当かもしれません。

A simple flowchart diagram. A file (rectangle node) main.ts resolves (labeled arrow) through module specifier './math' to another file math.ts.

これは完全に間違っているわけではありませんが、現実はより深く理解する必要があります。"./math"の解決(そしてその結果、addの型)は、出力ファイルに対してランタイムで発生することの現実を反映する必要があります。このプロセスについてより堅牢な考え方は、次のようになります。

A flowchart diagram with two groups of files: Input files and Output files. main.ts (an input file) maps to output file main.js, which resolves through the module specifier "./math" to math.js (another output file), which maps back to the input file math.ts.

このモデルは、TypeScriptにとってモジュール解決が、タイプ情報を見つけるために適用される少しのリマッピングを伴い、主に、出力ファイル間のホストのモジュール解決アルゴリズムを正確にモデル化することであることを明確にしています。単純なモデルの観点からすると直感的ではないように見えるが、堅牢なモデルでは完全に理にかなっている別の例を見てみましょう。

ts
// @moduleResolution: node16
// @rootDir: src
// @outDir: dist
// @Filename: src/math.mts
export function add(a: number, b: number) {
return a + b;
}
// @Filename: src/main.mts
import { add } from "./math.mjs";
add(1, 2);

Node.jsのESM import宣言は、ファイル拡張子を含める必要がある厳密なモジュール解決アルゴリズムを使用します。入力ファイルについてのみ考えると、"./math.mjs"math.mtsに解決されるように見えるのは少し奇妙です。コンパイルされた出力を別のディレクトリに入れるためにoutDirを使用しているため、math.mjsmain.mtsの隣にさえ存在しません!なぜこれが解決されるのでしょうか?新しいメンタルモデルを使用すると、問題はありません。

A flowchart diagram with identical structure to the one above. There are two groups of files: Input files and Output files. src/main.mts (an input file) maps to output file dist/main.mjs, which resolves through module specifier "./math.mjs" to dist/math.mjs (another output file), which maps back to input file src/math.mts.

このメンタルモデルを理解しても、入力ファイルに出力ファイルの拡張子が表示されることの奇妙さがすぐになくなるわけではありません。また、次のようなショートカットで考えるのは自然なことです。"./math.mjs"は入力ファイルmath.mtsを指します。出力拡張子を記述する必要がありますが、コンパイラは.mjsと書くと.mtsを探すことを知っています。このショートカットは、コンパイラが内部的に動作する方法でもありますが、より堅牢なメンタルモデルは、TypeScriptでのモジュール解決がこの方法で機能する理由を説明します。出力ファイルのモジュール指定子が入力ファイルのモジュール指定子と同じであるという制約を考えると、これが、出力ファイルの検証と型の割り当てという2つの目標を達成する唯一のプロセスです。

宣言ファイルの役割

前の例では、モジュール解決の「リマッピング」部分が入力ファイルと出力ファイルの間で機能しているのを見ました。しかし、ライブラリコードをインポートするとどうなるでしょうか?ライブラリがTypeScriptで記述されていたとしても、そのソースコードを公開していない場合があります。ライブラリのJavaScriptファイルをTypeScriptファイルにマッピングすることに依存できない場合、インポートがランタイムで機能することを検証できますが、型を割り当てるという2番目の目標をどのように達成するのでしょうか?

これは、宣言ファイル(.d.ts.d.mtsなど)が登場する場所です。宣言ファイルがどのように解釈されるかを理解する最良の方法は、それらがどこから来るのかを理解することです。入力ファイルでtsc --declarationを実行すると、1つの出力JavaScriptファイルと1つの出力宣言ファイルが生成されます。

A diagram showing the relationship between different file types. A .ts file (top) has two arrows labeled 'generates' flowing to a .js file (bottom left) and a .d.ts file (bottom right). Another arrow labeled 'implies' points from the .d.ts file to the .js file.

この関係のため、コンパイラは、宣言ファイルが表示される場所には常に、宣言ファイル内の型情報によって完全に記述された対応するJavaScriptファイルがあることを想定しています。パフォーマンス上の理由から、すべてのモジュール解決モードで、コンパイラは常に最初にTypeScriptファイルと宣言ファイルを探し、見つかった場合は対応するJavaScriptファイルを探し続けることはありません。TypeScript入力ファイルが見つかった場合、コンパイル後にJavaScriptファイルが存在することを知っています。また、宣言ファイルが見つかった場合、(おそらく他の誰かの)コンパイルがすでに発生し、宣言ファイルと同時にJavaScriptファイルが作成されたことを知っています。

宣言ファイルは、コンパイラにJavaScriptファイルが存在することだけでなく、その名前と拡張子も伝えます。

宣言ファイルの拡張子 JavaScriptファイルの拡張子 TypeScriptファイルの拡張子
.d.ts .js .ts
.d.ts .js .tsx
.d.mts .mjs .mts
.d.cts .cjs .cts
.d.*.ts .*

最後の行は、モジュールシステムが非JSファイルをJavaScriptオブジェクトとしてインポートすることをサポートする場合に、allowArbitraryExtensionsコンパイラオプションを使用して非JSファイルに型を付けられることを示しています。たとえば、styles.cssという名前のファイルは、styles.d.css.tsという名前の宣言ファイルで表すことができます。

「しかし待ってください!多くの宣言ファイルは手作業で記述されており、tscによって生成されたものではありません。DefinitelyTypedについて聞いたことがあるでしょう?」と反論するかもしれません。そして、手書きの宣言ファイル、または外部ビルドツールの出力を表すためにそれらを移動/コピー/名前変更することは、危険でエラーが発生しやすい試みであることは事実です。DefinitelyTypedのコントリビューターと、JavaScriptと宣言ファイルの両方を生成するためにtscを使用していない型付きライブラリの作成者は、すべてのJavaScriptファイルに、同じ名前と一致する拡張子を持つ兄弟の宣言ファイルがあることを確認する必要があります。この構造から逸脱すると、エンドユーザーに偽陽性のTypeScriptエラーが発生する可能性があります。npmパッケージ@arethetypeswrong/cliは、それらが公開される前にこれらのエラーをキャッチして説明するのに役立ちます。

バンドラー、TypeScript ランタイム、Node.js ローダーのモジュール解決

これまで、私たちは *入力ファイル* と *出力ファイル* の区別を非常に強調してきました。相対モジュール指定子にファイル拡張子を指定する場合、TypeScript は通常、出力ファイル拡張子を使用させることを思い出してください。

ts
// @Filename: src/math.ts
export function add(a: number, b: number) {
return a + b;
}
// @Filename: src/main.ts
import { add } from "./math.ts";
// ^^^^^^^^^^^
// An import path can only end with a '.ts' extension when 'allowImportingTsExtensions' is enabled.

TypeScript は 拡張子を .js に書き換えることはないため、この制限が適用されます。また、出力 JS ファイルに "./math.ts" が出現した場合、そのインポートは実行時に別の JS ファイルに解決されません。TypeScript は、安全でない出力 JS ファイルの生成を本当に防ぎたいと考えています。しかし、もし出力 JS ファイルが *ない* 場合、どうでしょうか?次のいずれかの状況にある場合はどうでしょうか?

  • このコードをバンドルしていて、バンドラーは TypeScript ファイルをインメモリでトランスパイルするように構成されており、最終的には記述したすべてのインポートを消費して削除し、バンドルを生成します。
  • Deno や Bun のような TypeScript ランタイムでこのコードを直接実行しています。
  • Node 用の ts-nodetsx、または別のトランスパイルローダーを使用しています。

これらの場合、noEmit(または emitDeclarationOnly)と allowImportingTsExtensions を有効にして、安全でない JavaScript ファイルの出力を無効にし、.ts 拡張子のインポートに関するエラーを抑制できます。

allowImportingTsExtensions の有無にかかわらず、モジュール解決ホストに最適な moduleResolution 設定を選択することは依然として重要です。バンドラーと Bun ランタイムの場合、それは bundler です。これらのモジュールリゾルバーは Node.js に触発されましたが、Node.js がインポートに適用する 拡張子検索を無効にする厳密な ESM 解決アルゴリズムを採用しませんでした。bundler モジュール解決設定はこれを反映しており、node16nodenext のように package.json"exports" サポートを有効にしつつ、常に拡張子なしのインポートを許可します。詳細については、コンパイラーオプションの選択 を参照してください。

ライブラリのモジュール解決

アプリケーションをコンパイルする場合、モジュール解決の ホストに基づいて、TypeScript プロジェクトの moduleResolution オプションを選択します。ライブラリをコンパイルする場合、出力コードがどこで実行されるかはわかりませんが、可能な限り多くの場所で実行できるようにしたいと考えます。"module": "nodenext" (および暗黙の "moduleResolution": "nodenext") を使用することは、出力 JavaScript のモジュール指定子の互換性を最大限に高めるための最良の選択肢です。なぜなら、Node.js の import モジュール解決に対するより厳格なルールに従うことを強制するためです。ライブラリが "moduleResolution": "bundler" (またはさらに悪いことに "node10") でコンパイルした場合に何が起こるかを見てみましょう。

ts
export * from "./utils";

./utils.ts (または ./utils/index.ts) が存在すると仮定すると、バンドラーはこのコードで問題なく動作するため、"moduleResolution": "bundler" はエラーを出しません。"module": "esnext" でコンパイルした場合、この export ステートメントの出力 JavaScript は入力とまったく同じように見えます。その JavaScript が npm に公開された場合、バンドラーを使用するプロジェクトでは使用できますが、Node.js で実行するとエラーが発生します。

Error [ERR_MODULE_NOT_FOUND]: Cannot find module '.../node_modules/dependency/utils' imported from .../node_modules/dependency/index.js Did you mean to import ./utils.js?

一方、次のように記述した場合

ts
export * from "./utils.js";

これは、Node.js とバンドラーの両方で動作する出力を生成します。

要するに、"moduleResolution": "bundler" は伝染性があり、バンドラーでのみ動作するコードを生成することができます。同様に、"moduleResolution": "nodenext" は出力が Node.js で動作することのみを確認していますが、ほとんどの場合、Node.js で動作するモジュールコードは他のランタイムやバンドラーでも動作します。

もちろん、このガイダンスは、ライブラリが tsc からの出力を提供する場合にのみ適用できます。ライブラリが *出荷前* にバンドルされている場合、"moduleResolution": "bundler" は許容される場合があります。モジュール形式またはモジュール指定子を変更してライブラリの最終ビルドを作成するビルドツールは、製品のモジュールコードの安全性と互換性を保証する責任を負い、tsc は実行時にどのようなモジュールコードが存在するかを知ることができないため、そのタスクに貢献できなくなります。

TypeScript ドキュメントはオープンソースプロジェクトです。これらのページを改善するために、プルリクエストを送信してください ❤

このページの貢献者
ABAndrew Branch (6)

最終更新日: 2024年3月21日