保存するたびに一瞬固まる
VSCode で TypeScript のファイルを保存すると、フォーマットが走るまでに妙な間がある。
毎回ではない。ファイルによっては一瞬で終わるのに、ときどき数秒待たされる。
同時に、変数にホバーしても「読み込み中…」のまま型が出てこないことがある。しばらく待つと表示される。
犯人は tsserver、TypeScript の言語サーバーがメモリを食い切って落ちていた。
問題は「ファイル数」ではなく「1ファイルあたりのコスト」にある。
ホバーの「読み込み中」
ホバー時のツールチップを出しているのは Prettier ではない。tsserver だ。「読み込み中…」で止まるのは、tsserver がリクエストに返事できていない状態を意味する。
つまり tsserver が重い。
tsc を叩いたら落ちた
エディタ越しに調べるより、CLI で直接測る方が早い。
npx tsc --noEmit --extendedDiagnostics
--extendedDiagnostics を付けると、読み込んだファイル数、型の生成数、チェックにかかった時間、使用メモリが出力される。
このとき、コマンドが完走せず OOMErrorHandler を含むスタックトレースを吐いて停止した。ヒープ不足だ。
Node のデフォルトのヒープ上限は環境にもよるがおおむね 2〜4GB。tsc がそれを超えたということは、同じプロジェクトを読む tsserver も常に上限ギリギリで動いていることになる。
保存時フォーマットもホバーも「たまに」遅かった理由がこれで説明できる。たまたま重い処理が重なった瞬間に GC が走り、その間すべてが止まっていた。断続的に固まるのは、慢性的なメモリ逼迫の典型的な症状だ。
ファイル数を数える、型チェックを飛ばす
OOM で落ちるなら診断もできない。そこで型チェックを行わず、ファイル解決だけを実行する。
npx tsc --noEmit --listFilesOnly > files.txt
--listFilesOnly は依存を辿ってコンパイル対象を確定させるところまでで止まる。型チェックをしないのでメモリをほとんど使わず、落ちているプロジェクトでも完走する。
結果を数える。
wc -l files.txt
grep -c node_modules files.txt
実際の値はこうだった。
| 項目 | ファイル数 |
|---|---|
| 合計 | 4031 |
| node_modules 配下 | 3694 |
| 自前コード | 約 337 |
337 ファイル。小規模と言っていい。
この数字が切り分けを一気に進める。TypeScript がメモリを使い切る原因は大きく二つ、ファイル数の多さか、型の複雑さかだ。数万ファイル規模なら前者を疑う。337 で落ちるなら後者しかない。
pnpm を使っている場合は pnpm exec tsc ... に読み替える。モノレポでは実行するパッケージのディレクトリに cd してから叩かないと、意図しない tsconfig.json が拾われる。
ファイル数と型の複雑さは別の問題
ここは混同しやすいので分けて考えたい。
ファイル数が多い場合、症状は「起動が遅い」「初回のチェックが長い」になる。原因は include が広すぎる、exclude を書いていないので dist や coverage を拾っている、types を明示していないので node_modules/@types 配下を全部読んでいる、といったところだ。
一方、型の複雑さが原因の場合、ファイル数は少ないのにメモリと時間だけが伸びる。深い条件型、再帰的な型、巨大な union、テンプレートリテラル型の組み合わせなどで、型の展開回数が指数的に増える。--extendedDiagnostics の Instantiations が数百万を超えていたら、まずこれを疑う。
依存パッケージ側の型定義が原因の場合もある。
読み込まれている型定義の内訳は files.txt を集計すれば見えるが、パスの構造次第で集計コマンドが空振りすることがある。深追いする前に、次の切り分けを先にやった方がいい。
まず落ちない状態を作る
原因の特定には tsc を完走させる必要がある。落ちたままでは何も測れない。順序としてヒープ上限の引き上げが先に来る。
NODE_OPTIONS=--max-old-space-size=8192 pnpm exec tsc --noEmit
これで通れば、VSCode 側にも同じ設定を入れる。settings.json に一行。
"typescript.tsserver.maxTsServerMemory": 8192
保存してウィンドウをリロードすれば、ホバーの「読み込み中」も保存時フォーマットの遅さも解消する。
maxTsServerMemory は VSCode の正規の設定項目で、大規模プロジェクトでは引き上げが推奨されている。裏技ではない。
ただし対症療法であることは自覚しておきたい。自前 337 ファイルでデフォルトを超えるのは本来おかしい。数週間後にまた落ちるなら、型が増え続けているサインだ。
犯人ファイルを特定する
完走するようになったら、改めて計測する。
npx tsc --noEmit --extendedDiagnostics
見るべき数値は次の通り。
| 項目 | 意味 | 目安 |
|---|---|---|
| Files | 読み込んだファイル数 | 数千を超えると重い |
| Instantiations | 型の展開回数 | 数百万超なら型が犯人 |
| Check time | 型チェックの所要時間 | ここが支配的なら型側 |
| Memory used | 使用メモリ | 上限に近ければ危険 |
Check time が突出しているなら、トレースを取ってファイル単位まで絞り込める。
npx tsc --noEmit --generateTrace ./trace
npx @typescript/analyze-trace ./trace
analyze-trace は「どのファイルの、どの型のチェックに何ミリ秒かかったか」をホットスポット順に出力する。ここまで来れば修正対象は具体的なコード行として特定できる。
なお VSCode 側のログを直接読む手もある。
"typescript.tsserver.log": "verbose"
リロード後にコマンドパレットから TypeScript: Open TS Server Log を開くと、Project '/path/tsconfig.json' (Configured) の直後に Files (1234) という行が出る。ただしログは有効な間ずっとディスクに書き込まれ続け、それ自体が動作を遅くする。調査が終わったら必ず off に戻すこと。
切り分けの順序
同じ症状に出会ったときのために、辿った道筋を残しておく。
- エディタが遅いのか CLI が遅いのかを分ける。CLI だけなら
--cacheとファイル数の問題、エディタなら 1 ファイルあたりのコストの問題 - ホバーも同時に遅いか確認する。遅ければ Prettier ではなく tsserver
tsc --noEmit --extendedDiagnosticsを叩く。ここで OOM が出れば原因は確定- 落ちるなら
--listFilesOnlyでファイル数だけ数え、ファイル数の問題か型の問題かを判定する - ヒープ上限を上げて完走させ、
--generateTraceで犯人を特定する
Prettier の設定をいくら見直しても改善しなかったのは、そもそも見る場所が間違っていたからだった。フォーマッタが遅いという症状は、TypeScript プロジェクトの健康状態を映す鏡でもある。