Open11

TypeScriptのプロジェクトリファレンス(Project References)について

suinsuin

まずは公式ドキュメントをよく読む。

https://www.typescriptlang.org/docs/handbook/project-references.html

以下は公式ドキュメントの和訳。


プロジェクト参照

概要

プロジェクト参照は TypeScript 3.0 で導入された機能で、大規模な TypeScript プログラムを小さな部分に分割することができます。

この機能を利用することで、以下のような利点があります:

  • ビルド時間の大幅な改善
  • コンポーネント間の論理的な分離の強化
  • コードの新しい、より良い組織化方法の実現

また、プロジェクト参照と連携して動作する tsc の新しいモードである --build フラグも導入されました。これにより、より高速な TypeScript ビルドが可能になります。

サンプルプロジェクト

プロジェクト参照がどのように役立つか、一般的なプログラムの例を見てみましょう。
converterunits という2つのモジュールと、それぞれに対応するテストファイルがあるプロジェクトを想像してください:

/
├── src/
│   ├── converter.ts
│   └── units.ts
├── test/
│   ├── converter-tests.ts
│   └── units-tests.ts
└── tsconfig.json

テストファイルは実装ファイルをインポートしてテストを行います:

// converter-tests.ts
import * as converter from "../src/converter";

assert.areEqual(converter.celsiusToFahrenheit(0), 32);

従来、単一の tsconfig ファイルを使用する場合、この構造には以下のような問題がありました:

  1. 実装ファイルがテストファイルをインポートできてしまう
  2. src が出力フォルダ名に含まれないようにしながら、testsrc を同時にビルドすることが不可能
  3. 実装ファイルの内部を変更しただけで、エラーが発生しないにもかかわらず、テストの型チェックが必要になる
  4. テストだけを変更しても、実装の型チェックが必要になる

複数の tsconfig ファイルを使用することで、これらの問題の一部は解決できましたが、新たな問題が発生していました:

  1. ビルトインの最新性チェックがないため、常に tsc を2回実行する必要がある
  2. tsc を2回呼び出すことで、起動時のオーバーヘッドが増加する
  3. tsc -w が複数の設定ファイルで同時に実行できない

プロジェクト参照を使用することで、これらの問題をすべて解決し、さらに多くの利点を得ることができます。

プロジェクト参照とは

tsconfig.json ファイルに新しいトップレベルのプロパティ references が追加されました。これは参照するプロジェクトを指定するオブジェクトの配列です:

{
    "compilerOptions": {
        // 通常のオプション
    },
    "references": [
        { "path": "../src" }
    ]
}

各参照の path プロパティは、tsconfig.json ファイルを含むディレクトリ、または設定ファイル自体(任意の名前可)を指すことができます。

プロジェクトを参照すると、以下のような効果があります:

  1. 参照先プロジェクトからモジュールをインポートする際、その出力宣言ファイル(.d.ts)が読み込まれる
  2. 参照先プロジェクトが outFile を生成する場合、その出力ファイルの .d.ts ファイルの宣言が、このプロジェクトで見えるようになる
  3. ビルドモード(後述)では、必要に応じて参照先プロジェクトが自動的にビルドされる

複数のプロジェクトに分割することで、型チェックとコンパイルの速度が大幅に向上し、エディタ使用時のメモリ使用量が減少し、プログラムの論理的なグループ化の強化が図れます。

composite

参照されるプロジェクトでは、新しい composite 設定を有効にする必要があります。
この設定は、TypeScript が参照先プロジェクトの出力を迅速に見つけられるようにするために必要です。
composite フラグを有効にすると、いくつかの変更が発生します:

  1. rootDir が明示的に設定されていない場合、デフォルトで tsconfig ファイルを含むディレクトリになる
  2. すべての実装ファイルが include パターンにマッチするか、files 配列にリストされている必要がある。この制約に違反すると、tsc が指定されていないファイルを通知する
  3. declaration を有効にする必要がある

declarationMap

宣言のソースマップのサポートも追加されました。
declarationMap を有効にすると、対応するエディタでプロジェクトの境界を越えて「定義へ移動」や「名前の変更」などのエディタ機能を透過的に使用できるようになります。

outFile での prepend

参照のオプションで prepend を使用することで、依存関係の出力を前置することもできます:

   "references": [
       { "path": "../utils", "prepend": true }
   ]

プロジェクトを前置すると、そのプロジェクトの出力が現在のプロジェクトの出力の上に含まれます。
すべての出力ファイル(.js.d.ts.js.map.d.ts.map)が正しく出力されます。

tsc はこのプロセスで既存のディスク上のファイルのみを使用するため、あるプロジェクトの出力が結果のファイルに複数回現れる可能性がある場合、正しい出力ファイルを生成できないプロジェクトを作成することができます。
例えば:

   A
  ^ ^
 /   \
B     C
 ^   ^
  \ /
   D

この状況では、各参照で前置しないことが重要です。前置すると、D の出力に A が2回含まれることになり、予期せぬ結果につながる可能性があります。

プロジェクト参照の注意点

プロジェクト参照にはいくつかのトレードオフがあることを認識しておく必要があります。

依存プロジェクトは依存先からビルドされた .d.ts ファイルを使用するため、特定のビルド出力をチェックインするか、プロジェクトをクローンした後にビルドしてからでないと、エディタで不要なエラーを見ることなくプロジェクトをナビゲートできません。

VS Code を使用する場合(TS 3.7以降)、バックグラウンドでメモリ内 .d.ts 生成プロセスが動作し、この問題を軽減できますが、パフォーマンスへの影響があります。非常に大規模な複合プロジェクトでは、disableSourceOfProjectReferenceRedirect オプションを使用してこれを無効にすることができます。

さらに、既存のビルドワークフローとの互換性を維持するため、tsc--build スイッチで呼び出されない限り、依存関係を自動的にビルドしません。
次に、--build についてさらに詳しく見ていきましょう。

TypeScript のビルドモード

長年待ち望まれていた機能として、TypeScript プロジェクトのスマートな増分ビルドがあります。
3.0 では tsc--build フラグを使用できます。
これは tsc の新しいエントリーポイントで、単純なコンパイラというよりもビルドオーケストレーターのように動作します。

tsc --build(短縮形は tsc -b)を実行すると、以下の処理が行われます:

  1. 参照されているすべてのプロジェクトを見つける
  2. それらが最新かどうかを検出する
  3. 古くなったプロジェクトを正しい順序でビルドする

tsc -b に複数の設定ファイルパスを指定できます(例:tsc -b src test)。
tsc -p と同様に、設定ファイル名が tsconfig.json の場合、ファイル名自体を指定する必要はありません。

tsc -b コマンドライン

任意の数の設定ファイルを指定できます:

 > tsc -b                            # カレントディレクトリの tsconfig.json を使用
 > tsc -b src                        # src/tsconfig.json を使用
 > tsc -b foo/prd.tsconfig.json bar  # foo/prd.tsconfig.json と bar/tsconfig.json を使用

コマンドラインで渡すファイルの順序を気にする必要はありません - tsc は必要に応じて依存関係が常に先にビルドされるように並べ替えます。

tsc -b 特有のフラグもいくつかあります:

  • --verbose:何が起こっているかを説明する詳細なログを出力します(他のフラグと組み合わせ可能)
  • --dry:実際にはビルドせずに、何が行われるかを表示します
  • --clean:指定されたプロジェクトの出力を削除します(--dry と組み合わせ可能)
  • --force:すべてのプロジェクトが古くなったかのように動作します
  • --watch:ウォッチモード(--verbose 以外のフラグと組み合わせ不可)

注意事項

通常、tscnoEmitOnError が有効でない限り、構文エラーや型エラーがあっても出力(.js.d.ts)を生成します。
増分ビルドシステムでこれを行うと非常に問題があります - 古くなった依存関係に新しいエラーがある場合、一度しかそれを見ることができません。なぜなら、次のビルドでは最新のプロジェクトのビルドをスキップするからです。
このため、tsc -b は事実上、すべてのプロジェクトで noEmitOnError が有効になっているかのように動作します。

ビルド出力(.js.d.ts.d.ts.map など)をチェックインする場合、ソース管理ツールがローカルコピーとリモートコピーの間でタイムスタンプを保持するかどうかによっては、特定のソース管理操作の後に --force ビルドを実行する必要がある場合があります。

MSBuild

msbuild プロジェクトがある場合、proj ファイルに以下を追加することでビルドモードを有効にできます:

    <TypeScriptBuildMode>true</TypeScriptBuildMode>

これにより、自動増分ビルドとクリーニングが有効になります。

tsconfig.json / -p と同様に、既存の TypeScript プロジェクトプロパティは尊重されないことに注意してください - すべての設定は tsconfig ファイルで管理する必要があります。

一部のチームでは、tsconfig ファイルが、それらと対になっている管理プロジェクトと同じ 暗黙的な グラフ順序を持つように msbuild ベースのワークフローを設定しています。
あなたのソリューションがこのような場合、プロジェクト参照と共に tsc -p を使用して msbuild を引き続き使用できます。これらは完全に相互運用可能です。

ガイダンス

全体的な構造

より多くの tsconfig.json ファイルを使用する場合、通常は設定ファイルの継承を使用して、共通のコンパイラオプションを一元化することをお勧めします。
これにより、複数のファイルを編集する代わりに、1つのファイルで設定を変更できます。

もう一つの良い方法は、すべてのリーフノードプロジェクトへの references を持ち、files を空の配列に設定した "ソリューション" tsconfig.json ファイルを持つことです(そうしないと、ソリューションファイルがファイルの二重コンパイルを引き起こすため)。3.0 以降、tsconfig.json ファイルに少なくとも1つの reference がある場合、空の files 配列を持つことはもはやエラーではありません。

これにより、シンプルなエントリーポイントが提供されます。例えば、TypeScript リポジトリでは、すべてのサブプロジェクトを src/tsconfig.json にリストしているため、単に tsc -b src を実行するだけですべてのエンドポイントをビルドできます。

これらのパターンは TypeScript リポジトリで見ることができます - src/tsconfig_base.jsonsrc/tsconfig.jsonsrc/tsc/tsconfig.json が主要な例として挙げられます。

相対モジュール用の構造化

一般的に、相対モジュールを使用しているリポジトリを移行するのにそれほど多くの作業は必要ありません。
単に特定の親フォルダの各サブディレクトリに tsconfig.json ファイルを配置し、プログラムの意図したレイヤリングに合わせてこれらの設定ファイルに reference を追加します。
outDir を出力フォルダの明示的なサブフォルダに設定するか、rootDir をすべてのプロジェクトフォルダの共通ルートに設定する必要があります。

outFiles 用の構造化

outFile を使用するコンパイルのレイアウトは、相対パスがそれほど重要ではないため、より柔軟です。
留意すべき点は、「最後の」プロジェクトまで prepend を使用しないことです - これにより、ビルド時間が改善され、任意のビルドで必要な I/O 量が減少します。
TypeScript リポジトリ自体が良い参考例です - 私たちにはいくつかの「ライブラリ」プロジェクトと「エンドポイント」プロジェクトがあります。「エンドポイント」プロジェクトはできるだけ小さく保たれ、必要なライブラリのみを取り込んでいます。

suinsuin

Project Referencesが解決する主な課題

  1. ビルド時間の長さ:大規模プロジェクトでのコンパイル時間を短縮すること。

  2. コード構造の複雑さ:大きなコードベースを論理的に分割し、管理を容易にすること。

  3. 依存関係の管理:プロジェクト間の依存関係を明確に定義し、管理すること。

  4. 不必要な再コンパイル:変更されていない部分の再コンパイルを避け、効率を向上させること。

  5. 型チェックの遅延:大規模プロジェクトでの型チェックの遅さを改善すること。

  6. モジュール間の不適切な参照:プロジェクト境界を越えた不適切なインポートを防ぐこと。

  7. ビルドプロセスの複雑さ:複数の設定ファイルや複雑なビルドスクリプトの必要性を減らすこと。

suinsuin

Project Referencesが開発体験に与える影響

Project Referencesの導入により見込まれるDX(DevEx)の改善点は以下が考えられる:

  1. 高速なフィードバックループ:

    • ビルド時間の短縮により、コード変更後のフィードバックが迅速に得られる。
    • 部分的なビルドが可能になり、作業中の箇所に関連する部分のみを素早くチェックできる。
  2. IDEパフォーマンスの向上:

    • プロジェクトの分割により、各部分の型チェックが高速化され、IDEのレスポンスが向上する。
    • メモリ使用量の削減により、大規模プロジェクトでもIDEの動作が軽快になる。
  3. コードナビゲーションの改善:

    • declarationMap機能により、プロジェクト間のジャンプ(定義へ移動など)がスムーズになる。
  4. モジュール境界の明確化:

    • プロジェクト間の依存関係が明示的になり、アーキテクチャの理解が容易になる。
    • 不適切な依存関係を早期に発見できるため、コード品質の維持が容易になる。
  5. 柔軟な開発環境:

    • 大規模プロジェクトの一部のみを扱うことができ、開発者は必要な部分に集中できる。
  6. ビルドプロセスの簡素化:

    • tsc --buildモードにより、複雑なビルドスクリプトが不要になる。
  7. エラー検出の改善:

    • プロジェクト間の型の不一致や、不適切な依存関係をより早く、正確に検出できる。
  8. コラボレーションの向上:

    • プロジェクトの明確な分割により、チーム間の作業分担や責任範囲が明確になる。

これらの改善により、開発者はより生産的に、ストレスなく作業を進めることができ、コードの品質向上と開発速度の向上が期待できる。

suinsuin

Project Referencesの設定を管理するツール

Project Referencesを採用するような案件では、モノレポを採用しているケースが多いと考えられるが、モノレポにおけるパッケージ間の構造と、Project Referencesを同期する作業が発生する。これを自動化するツールはいくつかある。

個人的には、 @monorepo-utils/workspaces-to-typescript-project-references が最もフィットした。Moonrepoはpackage.jsonのdependencesだけでなく、moon.ymlのタスクの依存関係も考慮してしまい、不要な参照関係を作ってしまうことがあり、場合によっては循環依存を構築してしまうことがあった。 update-ts-referencescompilerOptions がない場合、勝手に空の "compilerOptions": {} を書き足してしまうのが、ちょっと残念なポイントだった。

suinsuin

TurborepoとProject References

Turborepoのドキュメントによれば、TurborepoではProject Referenceとの併用をおすすめしないとのこと。

You likely don't need TypeScript Project References

We don't recommend using TypeScript Project References as they introduce both another point of configuration as well as another caching layer to your workspace. Both of these can cause problems in your repository with little benefit, so we suggest avoiding them when using Turborepo.

この和訳

TypeScriptのProject Referencesはおそらく必要ありません
TypeScriptのProject Referencesは、設定箇所が増えるだけでなく、ワークスペースに別のキャッシュ層を導入することになるため、使用をお勧めしません。これらはリポジトリに問題を引き起こす可能性が高く、得られる利益が少ないため、Turborepoを使用する際には避けることをお勧めします。

https://turbo.build/repo/docs/guides/tools/typescript#you-likely-dont-need-typescript-project-references

Turborepoは、Turborepoで構築されたサンプルプロジェクトなどを見るに、パッケージごとに tsc を実行する戦略を促しているように見受けられる。Project Referencesを使わないとなると、開発中はパッケージごとに変更した際に tsc するか tsc --watch などをパッケージごとに常時起動するアプローチになると思うが、そう考えるとProject Referencesのほうが、開発体験や省コンピューティングリソースの点で勝っているように思える。したがって、個人的にはTurborepoの推奨するアプローチには懐疑的である。Turborepoに関して、ここらへんの認識が間違っていたら教えてほしい。

suinsuin

Project Referencesはメモリに優しい

Project Referenceを使わない方法では、上述したとおり以下の手段を取ることになる。

  • 変更があったパッケージごとに都度単発の tsc を実行する
  • 全パッケージにて tsc --watch (TypeScriptに変更があったらコンパイルするモード) を起動したうえで開発に臨む

前者は、開発者の認知負荷が高くあまり実践的でないように思われる。現実として使われるであろう後者のアプローチについて見ていく。

tsc --watch はパッケージごとに1プロセス起動することになる。そして、その1プロセスが使うメモリは関数が1つだけの小さなパッケージでも、120MB〜200MB程度のメモリを使う。仮に、10パッケージあるとすると、1.2GB〜2.0GB程度のメモリが必要になる。

tsc --build --watch のようにビルドモードをパッケージごとに起動するとどうなるか? --build は本来Project Referencesのための機能だが、 tsconfig.jsonreferences を設けなければ、実質的に tsc と同様に働く。この場合、メモリ使用量は40MB〜80MB程度だった。仮に、10パッケージあっても1GBに届かないので、tsc --watchよりはコスパが良くなる。

パッケージごとにプロセスを起動することのリソース面以外のデメリットとして、プロセス管理が必要になる点がある。すべてのtscが起動ししていることが期待されるが、何らかの理由で一部のtscが終了していなくなってしまうことがある。そうなった場合、終了したtscを復活させる必要がる。いくらマシンスペックが良くて、メモリが潤沢にあったとしても、このような複数のプロセスのおもりが必要になるのは、複数プロセスのアプローチのデメリットだと思う。

Project Referencesを用いた場合、起動するプロセスは1つだけでよく、そのプロセスですべてのパッケージのトランスパイルをカバーできる。これに要するメモリは、70MB程度だった。仮にパッケージ数が10パッケージであってもそこまで大きく変わってこないため、必要なマシンスペックも小さく済み、面倒を見るプロセスは1つだけになるため、プロセス管理の手間もほぼなくなる。

比較項目 各パッケージで tsc --watch 各パッケージで tsc --build --watch Project Reference
プロセス数 パッケージ数と同じ パッケージ数と同じ 1
1プロセスに必要なメモリ 120MB〜200MB 40MB〜80MB 約70MB
10プロジェクトに必要なメモリ 1.2GB〜2.0GB 400MB〜800MB 約70MB (パッケージ数による大きな変動なし)
suinsuin

コードジャンプとリファクタリング

node_modules経由での依存

  • 定義へジャンプ
    • vscode: OK
    • webstorm: OK
  • 使用箇所へジャンプ
    • vscode: OK
    • webstorm: NG
  • 定義箇所にてシンボルの変更
    • vscode: OK
    • webstorm: NG
  • 使用箇所にてシンボルの変更
    • vscode: OK
    • webstorm: NG
suinsuin

WebStorm 2024.2 RC (Build #WS-242.20224.227, built on August 1, 2024) でいろいろできない原因を探ったところ、TypeScript 5.5の${configDir} Template Variableを使っているせいだった。これを使わなければ、どれもOKっぽい。

suinsuin

configDirとは関係なく、exportsショートハンドにも対応していなそう。

suinsuin

configDirを使っていると、src/index.tsではなく、dist/index.d.tsを直しに行ってしまう。
configDir不使用だと、src/index.tsだけを直しにいってくれる。