TypeScript Project Referencesは、tsconfig.json同士の依存関係を定義することで、効率的なビルドが可能になる仕組みです。

Project Referencesの仕組みを使うことで、monorepoなど一つのリポジトリ内で複数のTypeScriptで書かれたパッケージがある場合に効率的なインクリメンタルビルドなどができます。 また、VSCodeなどのコード補完に使われるTS-Serverなどのスキャンも効率的になります。

たとえば、次のような構造で packages/front は packages/common に依存しているとします。

.
└── packages/
    ├── front/
    ├── server/
    └── common/

この場合にTypeScript Project Referencesで依存関係を表現するなら、packages/front の referencesにCommonへの相対パスを指定します。

// packages/front/tsconfig.json
{
  "references": [
    {"path": "../common"}
  ]
}

ビルドする場合には、tsc -p . ではなく tsc --build . というように --build フラグを使ってビルドします。 また、各tsconfig.jsonには compositedeclaration が必須となるなどの制限もあります。

詳しくはドキュメントを参照してください。

このTypeScript Project Referencesは便利は便利なのですが、設定が手間であるという問題が知られています。

多くのmonorepoではLernaYarn’s workspacesnpm’s workspacesなどを利用していると思います。 このworkspaceの設定と重複するreferencesの設定をtsconfig.jsonに手書きするのはあまり現実的ではありません。

そのため、monorepoのworkspaceの設定からtsconfig.jsonreferencesの設定を更新/チェックする@monorepo-utils/workspaces-to-typescript-project-referencesというツールを書きました。

@monorepo-utils/workspaces-to-typescript-project-references

似たようなツールはすでに何個もあるのですが、自分のニーズを満たせるものがなかったので作っています。

特徴としては次のようになっています。

  • monorepoのworkspace設定とTypeScript’s Project Referencesの同期とテスト
  • tsconfig.jsonのコメントは保持する
  • プラグインで任意のworkspaceツールに対応

インストール

npmなどを使ってインストールしてください

npm install @monorepo-utils/workspaces-to-typescript-project-references

使い方

CLIツールなのでHELPを見れば大体の使い方が分かります。

Usage
  $ workspaces-to-typescript-project-references


Options
  --root             [Path:string] Root directory of the monorepo. 
                     Default: current working directory

  --check            If set the flag, check only differences of tsconfig.json and does not update tsconfig.json.
                     If the check is failed, exit status 1. It is useful for testing.       
  --plugin           [Path:string] Path to plugin script.
                     Load the plugin script as module and use it. 
                       
  --tsconfigPath     [Path:string] Use alternative config path inside the package. e.g.: tsconfig.test.json
                     Default: tsconfig.json

Examples
  # Update project references in tsconfig.json
  $ workspaces-to-typescript-project-references
  # Test on CI
  $ workspaces-to-typescript-project-references --check

Examples

このツール自身がmonorepo-utilsというmonorepoで管理されています。 monorepo-utilsはlerna + yarn workspacesを使っています。

次の箇所にworkspacesの設定が書かれていることが分かります。

  "workspaces": {
    "packages": [
      "packages/*",
      "packages/@monorepo-utils/*"
    ]
  },

このmonorepo内で@monorepo-utils/workspaces-to-typescript-project-referencesパッケージの依存関係を見てみると、@monorepo-utils/package-utilsという別のパッケージに依存しています。

  "dependencies": {
    "@monorepo-utils/package-utils": "^2.2.0",
    "comment-json": "^3.0.3",
    "meow": "^7.1.1"
  }

そのため、workspaces-to-typescript-project-references コマンドを実行すると、 @monorepo-utils/workspaces-to-typescript-project-referencestsconfig.jsonに次のようにパスが追加されます。

  "references": [
    {
      "path": "../package-utils"
    }
  ]

これで、package.jsonを変更した際に workspaces-to-typescript-project-references コマンドを実行するだけで、常にtsconfig.jsonの定義を維持できます。 また、CIで workspaces-to-typescript-project-references --check を実行すれば、package.jsonとtsconfig.jsonのズレが出た場合に気付けるようになっています。

Plugin

@monorepo-utils/workspaces-to-typescript-project-referencesの特徴として、プラグインで任意のworkspaceに対応できるようになっています。 この機能を持ってるツールがなかったので、このツールを書いたところはあります。

たとえば、Boltというツールもworkspaceを持っていますが、package.jsonbolt.workspacesというフィールドを定義する独自の仕様です(yarnnpmやルート直下のworkspaceになっている)

このBoltの対応は、次のようなbolt-plugin.jsを書くことで対応できます。 (get-monorepo-packagesというライブラリはBoltをサポートしているので、これを使うだけです。)

bolt-plugin.js:

const getPackages = require("get-monorepo-packages");
const plugin = (options) => {
    const monorepoPackages = getPackages(options.rootDir);
    return {
        supports() {
            return monorepoPackages.length > 0;
        },
        getAllPackages() {
            return monorepoPackages;
        },
        getDependencies(packageJSON) {
            const dependencies = Object.entries(packageJSON.dependencies ?? {});
            const devDependencies = Object.entries(packageJSON.devDependencies ?? {});
            return [...dependencies, ...devDependencies].map((dep) => {
                return {
                    name: dep[0]
                };
            });
        },
        resolve({ name }) {
            const matchPkg = monorepoPackages.find((info) => {
                return info.package.name === name;
            });
            if (!matchPkg) {
                return null;
            }
            return matchPkg.location;
        }
    };
};
module.exports.plugin = plugin;

プラグインを使うときは、--pluginplugin.js のパスを渡すだけです。

$ npm install @monorepo-utils/workspaces-to-typescript-project-references -g
$ workspaces-to-typescript-project-references --plugin ./bolt-plugin.js

おわりに

TypeScriptで書かれた大きなプロジェクトではTypeScript Project Referencesを使うことでコンパイル速度などが最適化できます。

大きなTypeScriptのプロジェクトは増えていると思うので、Project Referencesも使われることが増えていると思います。 しかし、現状のProject Referencesは使い勝手があまりよい感じのものではありません。

@monorepo-utils/workspaces-to-typescript-project-referencesはProject Referencesを補助するツールですが、将来的にTypeScript自体でもうちょっと良い感じに使えるようになるのがいい気がします。

Project Referencesを使わないで paths のaliasを使って別パッケージのtsファイルを直接参照してビルドするという裏技もありますが、かなり罠っぽい動作もあるので、素直にProject Referencesを使ったほうがまともだと思います。

textlintはTypeScriptで書かれているパッケージが多いので、Project Referencesと@monorepo-utils/workspaces-to-typescript-project-referencesを使うようになっています。

TypeScript Project Referencesを現状使うべきかは迷うような感じではありますが、Project Referencesを使うにはtsc --buildでビルドする必要があるという制約があります。 そのため、Project Referencesを入れると必然的にTypeScriptのビルドキャッシュの仕組みが導入されるという副作用があります。

tsc --buildを使うと.tsbuildinfoを使ったビルドのファイルキャッシュが作られます。 これは、すでにビルドキャッシュがあると一から再ビルドしなくてすむので、monorepoのようなビルドステップが複雑になりがちな場所では結構有用です。

Project Referencesはまだ実例があんまり多くなかったりエコシステム側の対応がいまいちという部分はありますが、徐々に良くなっているので触れる機会は増えるのかもしれません。