TypeScript の Go 移植に備えて知っておくべきこと
はじめに
こんにちは、ダイニーの ogino です。
TypeScript のコンパイラは今まで TypeScript で実装(セルフホスト)されていました。
それが TypeScript 7.0 から、Go による実装に移植され、10 倍高速になります。
本記事は、移植に関して筆者が疑問に感じた点を、GitHub discussion や TypeScript lead architect のインタビュー動画などから調べてまとめたものです。
移行の背景
今回 Go に移植される背景は、大規模な TypeScript コードベースをコンパイルする際のスピードの遅さにあります。
例えば VSCode のコードベース (150 万行) に対して tsc
を実行すると、約 80 秒もかかります。
TypeScript のコンパイルは大きく以下の段階に分けられ、その内の check が特に複雑で重い処理になっています。
- Parse: 文字列を読み込んで AST (抽象構文木) に変換する
- Check: 型チェックを行う
- Emit: JavaScript に変換して出力する
TypeScript を高速にコンパイルできるネイティブのツールとして、既に esbuild (Go) や swc (Rust) などがありますが、いずれのツールも check の機能は実装されておらず、parse, emit だけを行います。
というのも、TypeScript の型システムは常軌を逸した複雑さになっており、他言語で再実装するのが実質不可能だからです。
Check のロジックは checker.ts というファイルに集約されており、このファイルだけで約 5 万行に上ります。(そのため GitHub 上で普通に表示することができません)
サードパーティ製のツールで型チェックをするには、このバカでかい checker.ts を再実装するだけでなく、常に公式の機能変更に追従し続ける必要があり、現実的ではありません。
実際に型チェッカーを Rust で実装するプロジェクト (stc) は過去にありましたが、現在ではアーカイブされています。
今回の Go への移植は、公式だからこそ実現できたものと言えるかもしれません。
どのようにして移植したのか
今回の移植で特に大事にされているのは、「TypeScript 版と Go 版で互換性を保ち、細かな挙動やバグに至るまで完全に再現する」という点です。
そのため、ゼロからコードを書き直す (rewrite) のではなく、元のコードを行単位で忠実に再現する (port) というアプローチが取られています。
Go (左) と TypeScript (右) で書かれた checker の比較。
https://youtu.be/pNlq-EVld70?t=283 より
TypeScript 利用者への影響は?
コンパイルが 10 倍速くなる
下の表は公式記事から引用したもので、新旧の tsc
の実行時間を比較しています。
Codebase | Size (LOC) | Current | Native | Speedup |
---|---|---|---|---|
VS Code | 1,505,000 | 77.8s | 7.5s | 10.4x |
Playwright | 356,000 | 11.1s | 1.1s | 10.1x |
TypeORM | 270,000 | 17.5s | 1.3s | 13.5x |
date-fns | 104,000 | 6.5s | 0.7s | 9.5x |
tRPC (server + client) | 18,000 | 5.5s | 0.6s | 9.1x |
rxjs (observable) | 2,100 | 1.1s | 0.1s | 11.0x |
実行時のパフォーマンスは変わらない
今回の変更により、TypeScript で書かれたアプリケーションの実行速度が速くなる、ということは一切ありません。
あくまでも TypeScript コンパイラの実装言語が Go になるだけで、コンパイル先のターゲット言語が Go やネイティブコードに変わるわけではありません。
型チェックの結果は同一になる
移植の前後で 99.99 % の互換性を目指す、とされています[1]。
つまり、同じコードベースをコンパイルした時にコンパイル結果やエラーの内容が変わることは (ほぼ) 無く、drop-in replacement として使えることが期待されます。
とはいえ、完全に同じ挙動にするのはやはり難しいはずなので、以下のようなロードマップで移行が進められるようです。
- TypeScript 6.0: Go 版に合わせるため、一部機能を非推奨にしたり破壊的変更を入れる
- TypeScript 7.0: Go に移行
- その後約 2, 3 年は旧版もサポートする
Compiler API が使えなくなる?
TypeScript のコンパイラは CLI ツールとしてだけでなく、プログラムの中から呼び出すこともできます。そのインターフェースを the Compiler API と呼びます。
この API を利用している代表例として、typescript-eslint や ts-morph, typia などのツールがあります。
Compiler API の実態は、コンパイラ内部で使っている関数などをそのまま JS ライブラリとして公開しているだけなので、Go に移植後は利用できなくなります[2]。
Compiler API とはどういうものか
以下のスクリプトは、 Compiler API によってコードの型情報を解析する例です。指定されたファイル内に定義されている関数のサマリを取得します。
import * as ts from "typescript";
interface FunctionSummary {
name: string;
documentation: string;
returnType: string;
}
function getFunctionSummaries(filePath: string): FunctionSummary[] {
const program = ts.createProgram([filePath], {});
const checker = program.getTypeChecker();
const sourceFile = program.getSourceFile(filePath);
const summaries: FunctionSummary[] = [];
sourceFile?.forEachChild(visit);
return summaries;
// ⚠️⚠️⚠️ ここがメインの処理 ⚠️⚠️⚠️
function visit(node: ts.Node) {
if (ts.isFunctionDeclaration(node) && node.name) {
const signature = checker.getSignatureFromDeclaration(node);
if (signature) {
summaries.push({
name: node.name.text,
documentation: ts.displayPartsToString(signature.getDocumentationComment(checker)),
returnType: checker.typeToString(signature.getReturnType())
});
}
}
node.forEachChild(visit);
}
}
console.log(getFunctionSummaries(process.argv[2]));
このコードに以下のようなファイルを与えると、
/**
* x が偶数なら "even"、奇数なら "odd" を返す
*/
function foo(x: number) {
return x % 2 === 0 ? "even" : "odd";
}
foo(42);
次のような出力が得られます。
[{
name: "foo",
documentation: "x が偶数なら \"even\"、奇数なら \"odd\" を返す",
returnType: "\"even\" | \"odd\"",
}]
input.ts
の中では関数の返り値型を省略して書いていますが、出力には正しく推論された型 "even" | "odd"
が含まれています。
上のスクリプトが何をやっているか理解するには、 TypeScript AST Viewer で裏側のデータ構造を見てみるのが近道でしょう。
TypeScript AST Viewer で見た input.ts
の AST
中央に表示されているのが input.ts
の AST (抽象構文木) です。
先ほどのスクリプトは AST の中から FunctionDeclaration
ノードを探して、その名前や型の情報を取り出しています。
Compiler API の代替案
Compiler API は eslint などメジャーなツールも利用しており、単に無くしてしまうと大きな影響が出ます。代替手段に関しては下記の discussion がありますが、方針はまだ確定していません。
LSP (Language Server Protocol) サーバーのようなプロトコル間通信で API を提供するのが今の所は有力な案のようです。
その場合クライアントの言語によらず使えるメリットがある一方で、機能的にできることは今より大幅に制限されそうな懸念を感じます。少なくとも、現在 Compiler API に依存しているツールは大掛かりな改修が必要になるでしょう。
なぜ Go なのか?
Go が選ばれた理由は GitHub discussion やインタビュー動画で詳しく説明されています。
最も大きな理由は、元の TypeScript コードを「直訳」するのに Go が適しており、なおかつ最もネイティブだということです。
なぜ Rust ではないのか?
前述した通り、今回の移行は rewrite ではなく port です。仮に、しがらみの無い rewrite であれば Rust など他の言語の方がパフォーマンス的に優れていたかもしれません。しかし Rust では以下のような理由により、根本的にデータ構造などを見直さない限り移行できないため、port には不向きです。
- Garbage Collection が無い
- TypeScript コンパイラの扱うデータ構造には循環参照が至る所にあり、所有権ルールに引っかかる
AST viewer で見る循環参照の様子。関数 foo
の呼び出しの AST Node は Symbol foo
への参照を持つ。逆に Symbol も Node への参照を持つ。
なぜ C# ではないのか?
また、もう 1 つの検討対象として C# も挙げられています。
(TypeScript, C# は共に Microsoft 製の言語です。TypeScript の Lead Architect の Anders Hejlsberg は C# の設計者でもあります。)
C# が選ばれなかった理由として、インタビュー動画の中で次のように語られていました。
- C# は Go よりも低レイヤの制御がしづらい
- バイトコードへのコンパイルが主であり、AOT コンパイルも存在するものの枯れた技術ではない
- Go の方がメモリレイアウトなどをコントロールしやすい
- クラスベースの OOP が根強い言語であり、クラスをほぼ使わない TypeScript コンパイラのコードベースとミスマッチが大きい
- 約 5 万行の checker.ts の中にクラス定義は 1 行もない
実際に試すには
typescript-go のコードは下記のレポジトリで公開されており、実際にビルドして試すことができます。
README に記載されている通り、以下のコマンドを実行すると built/local/tsgo
が生成されます。
$ git clone --recurse-submodules https://github.com/microsoft/typescript-go.git
$ cd typescript-go
$ npm ci
$ npx hereby build
あとは tsc
と同じように tsgo
を実行すると、次のような diagnostic が表示されます。
$ /path/to/typescript-go/built/local/tsgo
Files: 9360
Types: 668859
Parse time: 0.950s
Bind time: 0.045s
Check time: 1.024s
Emit time: 4.415s
Total time: 6.434s
Memory used: 1445411K
Memory allocs: 16940831
実はこの結果はおかしく、Emit time
が Check time
より異常に長くなっています。(前述した通り、check が特に重い処理のはずです。)
もしあなたが Mac を使っていて同じような事象に遭遇したら、下記の discussion に書かれている方法を試してみてください。
/internal/vfs/osvfs/os.go
の writeFile
関数の先頭に以下の 2 行を足してビルドし直すだけです。
osReadSema <- struct{}{}
defer func() { <-osReadSema }()
筆者の手元では、この方法で 4.415 秒の Emit time が 0.527 秒に短縮されました。
パフォーマンス向上の要因
Go への移植によってコンパイル速度は約 10 倍になりました。
その要因を分解すると、ネイティブ化により 3 倍、並列化により更に 3 倍になったと説明されています[3]。
ネイティブ化によるメモリ効率改善
言うまでも無いことですが、JS より Go の方がメモリ効率の良いプログラムを書くのに適しています。
そのおかげで typescript-go ではメモリ使用量が半分ほどに削減されたようです。現代のマシンでは CPU のインストラクションよりメモリアクセスにかかる時間の方が何桁も大きいので、メモリ効率の改善はスピードの向上にも寄与します。
JS のメモリ効率を悪くする理由の一部として、数値型が number
しか存在しないことが挙げられます。
例えば、TypeScript コンパイラのコードには下記のような enum
とビット演算による flag 表現が頻出します。
「ある型が union または intersection に該当するか」を判定する場合は上記の enum
とビット論理積を使って次のように書きます。
number
型は 64 bit の浮動小数点数ですが、ビット演算子は数値を 32 bit 符号付き整数に変換するので、上記のパターンでは 31 bit 分の情報しか詰め込むことができません。つまり、無駄に倍以上のメモリを消費するわけです。
typescript-go ではこのパターンをほぼそのままの形で翻訳しつつ、uint32
型を利用することでメモリを節約しています。
並列化
コンパイラのステップの内 parser は並列化するのが非常に容易で、ファイルごとに独立して並列処理すればよいだけです。
一方 checker は、型情報にファイル間で依存関係があるため、そう単純にはいきません。
import { foo, Bar } from './foo';
const baz: Bar = foo(); // この型が合っているかチェックするには、別ファイルも参照しないといけない
typescript-go では、プロジェクトのファイルを 4 グループに分け、4 つの checker で並列処理するというアプローチを取っています[4]。
個々の checker は与えられた範囲内のファイルだけをチェックしますが、その過程で「担当範囲外」のファイルを参照することもあります。
丸がファイルを表す。import 先のファイルに対して矢印が伸びている。紫の丸は両方の checker から参照されるファイル
別の checker 同士は状態を共有しない設計になっているので、複数の checker が多重にチェックするファイルはその分余計にメモリを使います。
メモリ使用量は 20% ほど増加しますが、それと引き換えに約 3 倍のスピードアップ効果を得られたとのことです。
まとめ
TypeScript のコンパイラは極めて複雑な型システムを持ち、仕様書も無いので、他言語で実装するのは難しいだろうと筆者は思いこんでいました。そのため、公式が実現してしまったことには意表を突かれました。
今回の Go 移植は全体としてメリットが非常に大きく、よく考え抜かれた上での重大な決断だという印象を受けました。今後の動向に期待しています。
We're hiring!
ダイニーでは、フルスタック TypeScript で開発を行っています。ご興味をお持ちの方は、ぜひカジュアル面談にご応募ください。
Discussion
最初の方でこれを教えてくださるのは誠実な記事ですね。