📘

TypeScriptのProject Referencesを使ってソースコードを分割し、レイヤー間の依存関係を強制する

2021/08/11に公開

サマリ

  • Project Referencesを使うと、1つの巨大なtypescriptプロジェクトを、複数のプロジェクトに分割し、プロジェクト間の依存関係を整理することができる。これにより、例えば以下のようなことができる
    • test/ ディレクトリで export しているテスト用の関数を、 src/ ディレクトリの本番用コードでは参照できなくする
    • レイヤードアーキテクチャで設計しているとき、プレゼンテーション層で定義している関数を ドメイン層から参照できなくし、レイヤー間の依存関係を強制する
  • また、プロジェクトの分割によりビルド時のパフォーマンスが大幅に改善する。
    • …と公式で書いているが、ビルド時の挙動を正確に把握しないとむしろ悪化するので、注意が必要
  • 適切なプロジェクト分割を行う必要があること、eslintが遅くなること、 ts-nodets-jest がデフォルトで動かなくなることなど、運用するのはやや大変で、コストがメリットに見合うかはやや微妙(個人の感想)

サンプル

この記事では以下のサンプルを使って説明します。
詳細はレポジトリのコードを参考にしてください。
https://github.com/nullnull/typescript-project-references-sample

また、TypeScriptでWebバックエンド/フロントエンドの開発をするのを念頭にこの記事を書いています。それ以外のユースケースだと、多少勝手が違うかと思いますがご容赦ください。

Project Referencesとは

TypeScript 3.0から導入された機能です。
https://www.typescriptlang.org/docs/handbook/project-references.html

例えば以下のようなディレクトリ構成のとき、普通にビルドを行うと一度のビルドで a/ b/ c/ のディレクトリの *.ts ファイルをビルドするかと思います。

.
├── a
│   ├── a.ts
├── b
│   ├── b.ts
├── c
│   ├── c.ts
├── package.json
├── tsconfig.json
└── yarn.lock

Project Referencesの機能を使うには、ルートディレクトリの tsconfig.json で以下のような設定をしつつ、各ディレクトリでも tsconfig.json を設定します。
これを行うと、a/ のビルド後に、 b/ のビルドが行われ、最後に c/ のビルドが行われるといった挙動になります。

// tsconfig.json
{
  // ルートディレクトリのtsconfigではビルド対象のファイルを指定せず、ビルドが必要なプロジェクトを参照する
  "files": [],
  "references": [
    { "path": "a" },
    { "path": "b" },
    { "path": "c" }
  ]
}
// a/tsconfig.json
{
  "compilerOptions": {
    "outDir": "../dist/a",
    // 参照されるprojectは、compositeをtrueにする必要がある
    "composite": true,
  }
}

bがaに依存している場合、以下のような記述になります。

// b/tsconfig.json
{
  "compilerOptions": {
    "outDir": "../dist/b",
    "composite": true,
  },
  "references": [
    {
      "path": "../a"
    },
  ]
}

この機能を使うと各ディレクトリを独立したtypescriptプロジェクトとして定義することができ、また依存関係を明示することができます。上の例だと、aからはb,cを参照できず、bからはaしか参照できなくなります。

これにより、例えば以下のようなことが達成できます。

  • test/ ディレクトリで export しているテスト用の関数を、 src/ の本番用コードからは参照できなくする
  • レイヤードアーキテクチャで設計しているとき、 src/presentation で定義している関数を src/infra から参照できなくし、レイヤー間の依存関係を強制する

公式でも以下のように紹介されています。

By doing this, you can greatly improve build times, enforce logical separation between components, and organize your code in new and better ways.

yarn workspaceとの併用

必須ではないですが、yarn workspaceを使うとより各プロジェクトの独立性を高めることができます。
yarn workspaceの詳細については公式ドキュメントや以下の記事を参照してください。
https://classic.yarnpkg.com/en/docs/workspaces/
https://numb86-tech.hatenablog.com/entry/2020/07/21/155343

上記の例だと、例えば a/package.json を追加し、ここに googleapis というパッケージを追加すると、aプロジェクトのみが googleapisに依存することを明示することができます。

実際のところ、この指定に意味があるかと言うと多少微妙です。
yarn workspaceを使った場合に yarn install すると、各ディレクトリの **/package.json で定義したモジュールはルートディレクトリの node_modules/ に追加され、 tscnode_modules/ を参照します。
そのため、 b/ 以下で googleapis をimportしてもコンパイルエラーにはならず、指定したプロジェクト以外でもimportすることができてしまいます。

指定した場合のメリットを一応挙げておきます。

  • b/ 以下でvscodeなどでインテリセンスを使って googleapis を補完することができなくなり、インテリセンスを使った場合の体験が良くなる
  • eslintと併用する場合、 node/no-extraneous-import
    を使うと、aで使っているパッケージがpackage.jsonに記述されていないケースでエラーにすることができる。

ビルド時の挙動の調査

Project Referencesを使うと --incremental オプションがデフォルトでONになり、差分があったプロジェクトにのみビルドが走ります。
これにより、全体をビルドすると数分かかるプロジェクトで1行だけソースコードを変更するようなケースで、変更があったプロジェクトでのみビルドが走るため非常に高速になります。

この挙動をより詳しく追ってみましょう。

tsc -b -v --extendedDiagnostics --listFiles というコマンドでビルド時の挙動を詳しく表示できます。
更に詳しく調査したい場合は、 --generateTrace オプションを使ってください。
※この辺のオプションの詳細は、以下の公式ドキュメントを参考にしてください。
https://github.com/microsoft/TypeScript/wiki/Performance#investigating-issues

サンプルレポジトリでは c -> b -> a という形でプロジェクトの依存関係があり、 a プロジェクトで googleapis という重いパッケージを参照しています。
googleapis を参照するだけで手元のマシンで6secほどビルド時間が伸びます。実験のためこうしていますが、本番運用では各パッケージを個別でimportするのが推奨されています)

一度ビルドをした後に、 c/c.ts を変更して再度ビルドを行うと、cとは関係のない googleapis は参照されず、高速にビルドが行われます。
Project Referencesを使わずにソースコード全体に対してビルドをすると、毎回 googleapis が参照されビルドが遅くなるため、これがProject Referencesのメリットと言えます。

しかし a/a.tsb/b.ts を変更してビルドを行うと、 a をビルドする時に googleapis を参照するだけでなく、 b をビルドするときも googleapis が参照され、二重に参照が発生するため合計のビルド時間がかなり長くなります。これは、aでexportしている関数の返り値が googleapis の型であるためです。
すなわち、依存関係に応じて同じファイル群が何度も読み込まれてしまい、普通に単一のプロジェクトとしてビルドするよりもよりパフォーマンスが悪くなりうることに注意してください。

個人的には、これを気を使って開発するのがなかなか大変だと感じます。
googleapis の型をむやみにexportしないようにするくらいは気を付けることができると思うのですが、 googleapis に全く関係ない型を a.ts で定義し、それを c.ts からも参照してしまった場合、ビルドの際は c.tsa.d.ts を読み込みにいってしまい、結局cのビルド時にも googleapis が読み込まれてしまう…といったケースはかなり気づきづらいと思います。

eslint, ts-jestなど関連ツール

eslintでtypescriptの型情報を使っている場合、eslintのパフォーマンスは著しく悪くなります。
https://github.com/typescript-eslint/typescript-eslint/issues/2094

またts-jestやts-nodeなどts-*系のツールについて、筆者は詳細を追い切れていませんが、基本的にproject referencesがサポートされておらず、工夫が必要になるため注意してください。
確認した限りだと、ts-jestやts-nodeでは tsconfig.jsonreferences の記述を無視するような挙動をしていました。

(参考)
https://jakeginnivan.medium.com/breaking-down-typescript-project-references-260f77b95913

個人的には、以下のルールの運用が楽だと考えています。

  • project referencesを使わない形の従来のtsconfig.jsonファイルを別で作っておく( tsconfig.jest.json など)
  • ts-jestやts-nodeではそちらを参照する
  • tsconfig.jsontsconfig.jest.json でビルド結果に差が出ないよう、各プロジェクト配下の **/tsconfig.json はシンプルに保つ(例えば paths を使ってエイリアスを定義しないなど)

もしくは、eslint以外についてはts-*系のツールを使わず、tscのビルド結果をそのまま利用する形が良いかもしれません。

関連する議論

https://github.com/TypeStrong/ts-node/issues/897

https://github.com/kulshekhar/ts-jest/issues/1648

まとめ

レイヤードアーキテクチャを強制したり、一部の重いモジュールをプロジェクトとして切り出すことでビルドのパフォーマンス改善が狙えるなどといったメリットは大きいのですが、運用には正しい知識と苦労が必要そうでやや厳しそうな印象を受けました。

運用のノウハウがあればぜひコメントください。

参考ページ

パフォーマンス改善のノウハウについてはTypeScript公式にまとまっています。
https://github.com/microsoft/TypeScript/wiki/Performance

英語記事
https://turborepo.com/posts/you-might-not-need-typescript-project-references

Discussion