Help us understand the problem. What is going on with this article?

1時間超のTypeScriptコンパイルを数十秒に抑えてみる

この記事のソースコード: https://github.com/knjname/2020-09-03_growingtsd

TypeScriptプロジェクトのコンパイルが終わらない!

こんにちは型に弱いマンです。

CIでTypeScriptプロジェクトのコンパイルが1時間たっても終わらなかったことはありませんか? 私は何度かあります。

TypeScript的に行儀の良い(?)フレームワークを使っている方は、おそらくそういう現象に遭うことは少ないと思います。

行儀の悪い(?)フレームワークをハードに使っている方は、こういうことに遭う確率が高いと思います。

具体的にどういうフレームワークかというと、下記のようにコードで動的に型を紡ぎ出す系のフレームワークですね。 (mobx-state-tree なんかが該当します。)

export type ItemDefinitions<D = {}> = {
  define<T>(args: T): ItemDefinitions<D & { [P in keyof T]: T[P] }>;
};
const item = {} as ItemDefinitions

// 使い方
// オブジェクトをメソッドに渡してあげると、オブジェクトに応じて型がどんどん生えてくる
const loginForm = item.define({
  userName: "",
  password: "",
  passwordForConfirm: "",
})
// => ItemDefinitions<{} & {
//   userName: string,
//   password: string,
//   passwordforConfirm: string
// }>

上記のような型は、TypeScriptの型のしなやかさを余すところなく使っていて素晴らしいのですが、コードベースが小さいプロジェクト初期のうちはうまく回ったとしても、プロジェクトのコードベースが成長するにつれ、コンパイラのパフォーマンスがついていかなくなることがあります。

具体的には下記のような、この手の型が再帰していくケースで、どんどんTypeScriptコンパイラが速度的についていけなくなっていきます。

const loginPage = item.define({
  loginState: loginState,
  loginForm: loginForm, ...
})

const app = item.define({
  loginPage: loginPage,
  welcomePage: welcomePage, ...
})

私も上記のような型をどんどん成長させていったところ、気がついたらCIが1時間で完了しないモンスターを作り出していました。

最初はなんとなく、「webpackが重いのかな?」などと考えていましたが、webpackは無罪で、TypeScriptが重すぎて終わってなかったんです。VSCodeの補完や開発用のバンドルは動いちゃったりするので、CIまで気づきませんでした。

人為的にTypeScriptコンパイルを重くする実験

上記のようなコンパイラが重くなる現象を引き起こすために、下記のような意地悪な関数を考えてみます。

export const recurse = <T>(itemDef: ItemDefinitions<T>) =>
  itemDef.define({
    a: itemDef,
    b: itemDef,
  });

これがどういうことになるかというと、こんな感じになります。

export const item1 = recurse(item)
// => ItemDefinitions<{a: ItemDefinitions<{}>, b: ItemDefinitions<{}>}>
export const item2 = recurse(item1)
// => ItemDefinitions<{a: ItemDefinitions<{a: ItemDefinitions<{}>, b: ItemDefinitions<{}>}>, b: ItemDefinitions<{a: ItemDefinitions<{}>, b: ItemDefinitions<{}>}>>

爆発的に型情報を増やしてくれます。

ためしに上記を17回まで繰り返しまでやってみて、tscのコンパイル速度がどれぐらい劣化するのか、計測してみました。 (*.js / *.d.ts 出力有)

image.png

0: 1.66s
1: 1.97s
2: 1.29s
3: 1.57s
4: 1.63s
5: 2.86s
6: 4.39s
7: 6.04s
8: 12.80s
9: 42.14s
10: 76.45s
11: 96.01s
12: 126.75s
13: 175.48s
14: 225.90s
15: 257.27s
16: 301.13s
17: 371.20s

案外、後半部分は善戦していますが、まあ現実的には耐え難い速度です。*.d.ts ファイルもそのあたりになってくると 10MB を越え始めます。このへんからTypeScriptプロジェクトコンパイルが現実的な速度で終わらなくなってきます。(トランスパイルのみなら全然動作するけど、CRAのproduction buildとかで動かなくなったりする)

元のソースコードはこんなものなのですが、これが10MB超の定義を生み出すとは想像が付きませんね。

import { item0, recurse } from "./lib"
export const item1 = recurse(item0);
export const item2 = recurse(item1);
export const item3 = recurse(item2);
export const item4 = recurse(item3);
export const item5 = recurse(item4);
export const item6 = recurse(item5);
export const item7 = recurse(item6);
export const item8 = recurse(item7);
export const item9 = recurse(item8);
export const item10 = recurse(item9);
export const item11 = recurse(item10);
export const item12 = recurse(item11);
export const item13 = recurse(item12);
export const item14 = recurse(item13);
export const item15 = recurse(item14);
export const item16 = recurse(item15);
export const item17 = recurse(item16);

どうして重いのか?

これの *.d.ts ファイルを見ると一目瞭然なのですが、あらゆる型がそのまま推論された状態で展開されたまま使われているのが原因です。

重さを回避するには?

とにかく型の展開を防げればなんでもいいです。

今回はやや面倒ですが、型を推論させず手書きすることにしました。(ご利用のフレームワークによってはもっと色々回避方法があるかもしれません。)

import { item0, recurse, ItemDefinitions } from "./lib"
export const item1: ItemDefinitions<{a: typeof item0, b: typeof item0}> = recurse(item0);
export const item2: ItemDefinitions<{a: typeof item1, b: typeof item1}> = recurse(item1);
export const item3: ItemDefinitions<{a: typeof item2, b: typeof item2}> = recurse(item2);
export const item4: ItemDefinitions<{a: typeof item3, b: typeof item3}> = recurse(item3);
export const item5: ItemDefinitions<{a: typeof item4, b: typeof item4}> = recurse(item4);
export const item6: ItemDefinitions<{a: typeof item5, b: typeof item5}> = recurse(item5);
export const item7: ItemDefinitions<{a: typeof item6, b: typeof item6}> = recurse(item6);
export const item8: ItemDefinitions<{a: typeof item7, b: typeof item7}> = recurse(item7);
export const item9: ItemDefinitions<{a: typeof item8, b: typeof item8}> = recurse(item8);
export const item10: ItemDefinitions<{a: typeof item9, b: typeof item9}> = recurse(item9);
export const item11: ItemDefinitions<{a: typeof item10, b: typeof item10}> = recurse(item10);
export const item12: ItemDefinitions<{a: typeof item11, b: typeof item11}> = recurse(item11);
export const item13: ItemDefinitions<{a: typeof item12, b: typeof item12}> = recurse(item12);
export const item14: ItemDefinitions<{a: typeof item13, b: typeof item13}> = recurse(item13);
export const item15: ItemDefinitions<{a: typeof item14, b: typeof item14}> = recurse(item14);
export const item16: ItemDefinitions<{a: typeof item15, b: typeof item15}> = recurse(item15);
export const item17: ItemDefinitions<{a: typeof item16, b: typeof item16}> = recurse(item16);
export const item18: ItemDefinitions<{a: typeof item17, b: typeof item17}> = recurse(item17);

これの .d.ts ファイルは下記のようになります。

あ〜らスッキリ!

前回さんざんだったコンパイラの速度も下記の通り。

image.png

0: 1.00s
1: 1.38s
2: 1.05s
3: 1.17s
4: 1.13s
5: 1.41s
6: 1.11s
7: 1.02s
8: 1.03s
9: 1.08s
10: 1.19s
11: 1.11s
12: 1.09s
13: 1.07s
14: 1.19s
15: 1.07s
16: 1.16s
17: 1.15s

較べるほどもないほど速度が上がりました。

結局、重いTypeScriptプロジェクトへの対処方法は…

  • 複雑な型をそのまま展開させない
  • 型を名前付きで参照させる
  • 上記が怪しそうなら、一旦 tsc*.d.ts ファイルを出力させて型がどれぐらい巨大になっているか観察してみる

これに尽きます。

上記よりもうちょっとまともに型情報を付与したものの、同様の戦術で、私のコンパイルに1時間超かかるモンスタープロジェクトのコンパイル時間も、数十秒に抑えることができました。

knjname
Zenn: https://zenn.dev/knjname
http://knjname.hateblo.jp/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away