Journal / Tech
TypeScript 7 のネイティブコンパイラを、実際の案件で計測する
TypeScript のコンパイラが、Go による実装へ置き換えられようとしています。TypeScript 7 と呼ばれるこの版では、型チェックがおよそ 10 倍速くなるとされています。
数字だけでは判断がつかないので、手元の二つのプロジェクトで実際に計測しました。Next.js のサイト(TypeScript 約 135 ファイル)と、React のアプリ(約 460 ファイル)です。それぞれに現行の tsc 5.9.2 とネイティブ版の tsgo を同じ条件でかけ、速度・生成物・診断の三点で突き合わせています。
結論を先に述べると、速度の改善は確かなものでした。ただし、その手前と周辺に、数字だけでは見えない差がいくつかあります。順に見ていきます。
設定が現行のままでは、起動すらしない
tsgo を最初に走らせて返ってきたのは、速度ではなく設定ファイルのエラーでした。
error TS5108: Option 'target=ES5' has been removed. Please remove it from your configuration.
error TS5102: Option 'baseUrl' has been removed. Please remove it from your configuration.
Next.js 側では target: ES5 が、React 側では baseUrl が、いずれも「すでに削除されたオプション」として拒否されました。TypeScript は 7 の前段にあたる 6.0 で、長く使われてきた設定群を整理しています。その帰結がここに現れているわけです。設定を現行の形へ更新するまで、型チェックは一行も実行されません。
つまり移行で最初に手を動かすのは、速度以前に、この設定の手直しです。走らせて初めて表面化する類の作業でした。
速度差は、コード規模に比例して開く
設定を通したうえで計測した、二回目以降(キャッシュが温まった状態)のおおよその実測値です。
| プロジェクト | tsc 5.9.2 | ネイティブ tsgo |
|---|---|---|
| React アプリ(約 460 ファイル) | 約 11〜13 秒 | 約 1〜2 秒 |
| Next.js サイト(約 135 ファイル) | 約 2 秒 | 約 0.5 秒 |
React アプリでは 8〜10 倍、Next.js サイトでも 4 倍前後です。倍率だけ見れば一様に速いものの、短縮された絶対値は、React アプリが 10 秒あまり、Next.js サイトは 1.5 秒ほどと、大きく開きます。
この差の出どころは、型チェックのアルゴリズムそのものではありません。TypeScript 7 は型システムを変えておらず、速くなったのは実行のされ方です。要因はふたつあると考えられます。ひとつは、JavaScript の解釈や JIT を挟まないネイティブコード化。もうひとつは並列化で、単一スレッドの JavaScript では直列だった処理が、Go では並行して走ります。実測にもそれらしい痕跡がありました。tsc は React アプリの初回実行に約 34 秒を要し、二回目以降に 11〜13 秒へ落ち着きます。この初回ぶんの超過は JIT のウォームアップやファイル読み込みに由来するもので、ネイティブバイナリの tsgo は最初から 1〜2 秒で安定していました。倍率の一部は、この起動コストの有無が押し上げています。
もっとも、ここで測ったのは CLI での一括チェック一回分です。エディタ上では型チェックが編集のたびに走るため、補完や診断の応答という別の軸では、規模の小さいプロジェクトでも差が出るはずで、CLI の一回計測だけを見て「小規模には無縁」と結論づけるのは早計でしょう。ここは後日、言語サーバ側でも測ってみたいところです。
生成される JavaScript は一致するか
速度が上がっても、出力される JavaScript が変わってしまえば移行の意味は薄れます。そこで、同一の設定で両者に emit させ、生成物を一ファイルずつ突き合わせました。
比較的新しいターゲット(ES2020)では、出力は完全に一致しました。差が出たのはビルドの内部キャッシュだけです。
より厳しい条件として、ターゲットを ES2015 まで下げ、オプショナルチェイニングや async/await のダウンレベル変換が働く状態でも比較しました。ここでは 132 ファイル中 3 ファイルに差が出ます。ただし中身を確認すると、差異は生成コードの整形——改行とインデント——に限られていました。たとえば tsc が
.map(c => {
var _a;
return ({ id: c.id, /* …略… */ });
})
と展開する箇所を、tsgo は
.map(c => { var _a; return ({ id: c.id, /* …略… */ }); })
と一行にまとめています。挿入される一時変数も、変換後のロジックも同一で、意味論は保存されています。バンドラや minifier を通せば消える差です。
型情報を除去するだけの箇所は完全に一致し、変換を伴う箇所でのみ、しかも整形レベルの差にとどまる。出力の互換性という一点では、十分に信頼できる結果でした。
この結果は、移植の方針とも符合します。ネイティブ版は型システムを設計し直したものではなく、既存のコンパイラを Go へ構造ごと移したものです。変換のロジックが同じ実装に由来する以上、生成される意味論が一致するのは道理で、差が出るとすれば、抽象構文木を文字列へ起こす整形処理——プリンタ——の細部に限られる。実測の差分は、まさにその範囲に収まっていました。
診断は、完全には一致しない
型検査そのものを実装し直している以上、周辺的な診断が現行と食い違う箇所が出るのは避けられません。実際、Next.js の案件では、CSS を副作用としてインポートしている箇所を tsgo が「型が見つからない」と報告しました。tsc では通っていたものです。
この例は、CSS のような非コード資産をどう型付けするか、というモジュール解決の周辺部での差です。中核の型検査ではなく、その外縁でまだ足並みがそろっていない、と見るのが妥当でしょう。致命的ではないものの、単純な置き換えで済まないことは確かで、移行の際はこの種のずれを一件ずつ確認していくことになります。
まとめ
三点を測って見えたのは、次のような像です。速度の改善は本物で、規模の大きいコードベースほど効く。生成物の互換性は高く、差は整形の範囲にとどまる。一方で、設定の更新と診断のずれという、数字に表れない手当てが移行の前後に必要になる。
「置き換えれば速くなる」ではなく、「設定を更新し、いくつかの差分に対処したうえで、速くなる」。順序としては、速度は最後に来ます。とはいえ大規模案件での短縮は十分に魅力的で、移行の進め方は、これから手を動かしながら詰めていきます。