TypeScript の型検査にかかる時間を短縮した話

2024/05/01に公開

こんにちは。ナレッジワークの torii です。
最近、プロジェクトで使用している TypeScript の型検査にかかる時間を 3 割ほど短縮することに成功しました。
参考までにどのようにボトルネックを調査して改善に繋げたのかを書いてみます!

きっかけ

改善のきっかけは、たまたまネットを徘徊していて見つけた Zenn 記事でした。
(素晴らしい記事をありがとうございます!)

https://zenn.dev/forcia_tech/articles/20231017_tsuji

これを読んで「自社のプロダクトでも型検査にかかる時間を短縮できるのでは?」と思い立ち、試してみたところ実際に改善に役立てることができた、というのがこの記事の概要になります。

改善対象

改善対象は、弊社のメインプロダクトであるナレッジワークのフロントエンドです。現在マルチプロダクト化に向けたコード分割に取り組んでいる最中ですが、執筆時点はモノリシックな構成となっています。
改善前の TypeScript ファイルは自動生成されたコードを含めると約 29 万行あり、 tsc の実行に 50 秒ほどかかっていました。もちろん普段の開発ではインクリメンタルに型検査が行われるため、毎回これだけ待っているわけではありませんが、それでもコマンド実行時にかなり待たされているという感覚はありました。

調査結果

調査方法については上の記事との重複になってしまうので、ここでは簡単な説明にとどめます。

まず、tsc の --generateTrace オプションを使って実行結果のトレースを出力します。また、差分のみを検査の対象としないように --incremental オプションを false にします。

tsc --generateTrace /path/to/trace --incremental false

上記のコマンドにより、/path/to/trace ディレクトリに trace.json, types.json という 2 つの JSON ファイルが出力されます。ここで Chrome ブラウザで chrome://tracing にアクセスし、上記のうちの trace.json を読み込むと実行時間のグラフが表示されます。

以下は、今回の改善で実際に tsc が出力した実行結果のグラフです。

tscトレース

全体は大きく createProgram, bindSourceFile, checkSourceFile という3つのステップから構成されており、このうち最も長く時間をとっている checkSourceFile (黄色い帯)が型検査を行なっている部分になります。どうやら純粋に型検査だけで 30 秒ほどかかっているようです。また、 checkSourceFile と書かれた 1 つの帯が 1 ファイル分の処理を表しており、フォーカスすると遅くなっているファイルがどれなのかをすぐに確認できます。便利ですね。

(最初の createProgram はファイル読み込みやパースを行う部分なのですが、ここも初回の実行では 30 秒近くかかっていました。ここも改善の余地があるのかもしれませんが、今回は深く追っていません。)

改善方法

ここで、特に時間のかかっている 4 つの部分に注目することにしました。(着手順に番号を振ったため右から順番になっていますがご容赦ください)

  1. ts-proto により自動生成された validate.ts: 14% (7 秒)
  2. 自前で自動生成している MSW handler: 4% (2 秒)
  3. 巨大なパターンマッチ: 4% (2 秒)
  4. ts-proto により自動生成されたデータ変換のためのモジュール群: 10% (5 秒)

以下、それぞれの詳細を述べます(手っ取り早く解決策を知りたい場合は太字だけ読んでください)。

1. ts-proto により自動生成された validate.ts: 14% (7 秒)

ナレッジワークでは API の定義に Protobuf を用いて、バックエンドとフロントエンドのコードをそれぞれ自動生成しています。frontend 側のコード生成には ts-proto を使用しており、型定義やデータ変換のためのモジュールが一式生成されます。

今回の調査で最も時間がかかっていることが分かったのが、この ts-proto により生成された validate.ts というファイルでした。グラフを見ると、なんと 1 ファイルだけで 7 秒もかかっています。
調べた結果わかったのは、フロントエンドではこのコードを全く使用していないということでした。このコードだけ削除することもできましたが、今回はより手軽な方法として tsconfig.json の exclude にこのファイルを含めることにしました。たったこれだけで 14% (7 秒) を短縮することができました。

2. 自前で自動生成している MSW handler: 4% (2 秒)

ナレッジワークのフロントエンドでは、 API をモックするのに MSW (Mock Service Worker) をフル活用しています。テストや Storybook のほか、バックエンドよりも先行して開発中の機能を開発環境で動作させるためにも使用しています。

さて、この MSW を扱うために自動生成しているコードの中に、多数の API のハンドリングを一手に担う巨大なオブジェクトがありました。実際のコードを簡単に書き直すと以下のようなイメージです。

const mockCreators = {
  getXxx,
  postXxx,

  ...(250)
}

この巨大オブジェクトのキーは全て string ですが、値はそれぞれの API に合わせた別個の型になっています。
最初、このオブジェクトそのものが速度低下の原因なのかと思いましたが、そうではなく原因は次のコードにあったようです。

const mswHandlers = [
  ...Object.values(mockCreators).map((fn) => (param: unknown) => fn(param)),
];

Object.values() によって mockCreators の全ての値の型を結合した巨大なユニオンが生成されています。これが速度低下の原因になっていました。
しかし、よく調べると最終的に得られる mswHandlers の型は ((param: unknown) => HttpHandler)[] となっており、ユニオンは全く必要ないことが分かりました。そこで、以下のようにあらかじめ Record にキャストすることで不要なユニオン型を作らないようにしました

const mswHandlers = [
  ...Object.values(
    mockCreators as Record<string, (param: unknown) => HttpHandler>
  ),
];

これで 4% (2 秒) を短縮することができました。

3. 巨大なパターンマッチ: 4% (2 秒)

ナレッジワークのフロントエンドでは、 ts-pattern というライブラリも愛用しています。
switch 文を使うとどうしても手続き的に書かざるを得ない処理も ts-pattern を使えば、宣言的かつ簡潔に記述できます。また、より複雑な分岐を型安全に書くこともできます。

しかし、特に巨大なユニオンを扱う場合、型の計算コストが switch 文に比べてかなり大きいことがわかりました。

match(obj)
  .with({ type: "a" }, () => ...)
  .with({ type: "b" }, () => ...)
  .with({ type: "c" }, () => ...)

  ...(沢山)

検証の結果、特に大きなユニオン型を扱っている数箇所を switch 文に書き直すことで、約 4% (2 秒) を短縮できることがわかりました。
しかし、最終的にこの対応は見送ることになりました。普段の開発ではインクリメンタルに型検査が行われるため 1 ファイルあたりの遅延は十分小さく、一部だけ例外的に switch 文を使うほどのメリットはないだろうというのが理由です。

4. ts-proto により自動生成されたデータ変換のためのモジュール群: 10% (5 秒)

1 と同様に ts-proto によって生成されたデータ変換のためのコードが原因で遅くなっていました。
ただし、こちらは 1 ファイルではなく、多数のファイルで少しずつ遅くなっているようでした。

ここで、 ts-proto が生成するオブジェクトに注目しました。
例えば FooObject という型のオブジェクトを生成する場合、以下のようにデータ変換のための関数がセットで生成されます。

export const FooObject = {
  fromJSON(object: any): FooObject {
    return { ... };
  },
  toJSON(message: FooObject): unknown {
    return { ... };
  },
  create<I extends Exact<DeepPartial<FooObject>, I>>(base?: I): FooObject {
    return ...;
  },
  fromPartial<I extends Exact<DeepPartial<FooObject>, I>>(object: I): FooObject {
    return ...;
  },
};

眺めてみると <I extends Exact<DeepPartial<FooObject>, I>> という、いかにも計算に時間のかかりそうな型の記述が目につきます。そこで、試しに幾つかのファイルで create()fromPartial() を削除してみたところ、そのファイルの分の時間が削減されていることがわかりました。

さらに調べてみると、この create()fromPartial() は実際には全く使われておらず、全てのオブジェクトからこの2つの関数を削除できることがわかりました。幸運だったのは、ts-proto にこの2つの関数の生成を省略するためのオプションが用意されていたことです。

そこで、シェルスクリプトに以下の行を追加して不要なコードを生成しないようにしました

  protoc \

    ...

+   --ts_proto_opt=outputPartialMethods=false \

    ...

これにより全てのオブジェクトから不要な関数と複雑な型がなくなり、合わせて 10% (5 秒) ほど短縮することができました。
ついでにコードも 6 万行ほど減りました。

感想

tsc がこんなに手軽で有用な調査方法を提供しているのはとても嬉しいですね!
正直、一番時間をかけてモリモリ計算していた型が未使用だったのはショッキングでした(苦笑)

あとは「巨大なユニオンを使うと遅くなりがち」という傾向もなんとなく見えて興味深かったです(まあ実際に遅くなるまで意識する必要はないと思いますが)。

最後に、思いつきの改善に丁寧なレビューをしてくださったフロントエンド(ギルド)のメンバーに感謝です!


【宣伝】ナレッジワークでは技術発信する文化作りを推進しています(この記事もその一部です!)
ちょうど今月末の勉強会(Encraft)でその辺りの話をするので、ご興味のある方は是非いらしてください!

https://knowledgework.connpass.com/event/317520/


【募集】現在エンジニア積極採用中です!興味を持たれた方、是非カジュアル面談でお会いしましょう。

株式会社ナレッジワーク

Discussion