ゼスト Tech Blog

ゼストは「護りたい。その想いを護る。」をミッションに、在宅医療・介護業界向けのSaaSを開発しています。

TypeScript 7(tsgo)でCIの実行時間を半分にした話

はじめに

こんにちは、株式会社ゼストでエンジニアをしている山下です。

5月も後半ですが、少しずつ夏の暑さを感じる陽気。 型チェックを走らせる度に MacBook のファンが唸り熱気を放出しているので、そろそろエアコンを解禁するか毎日悩んでいます。

さて、先月の4月21日に、TypeScript 7.0のBetaがリリースされました。2025年3月の発表当時に、「tsc よりも最大10倍高速」と期待値爆上げの発表があってからはや一年です。

今回、弊社のCIの一部で、試験的にtsgoを導入してみました。

今回は、特にCI上でtsgoを動かすにあたって遭遇したいくつかの問題とその解決策、試行錯誤の過程を共有します。

TL;DR

長々とした本文の前に結論です。

  • ワークフロー単位の実行時間を、およそ半分まで短縮できました🎉

    • ただし、CIのコストは横ばい
  • CIに設定するときは、 GOMEMLIMIT とランナーのスペックに注意すべし

    • NODE_OPTIONS はもう使わないよ

以下、詳細を書いていきます。

前提

そもそもtsgoとは

2025年3月、Microsoft は TypeScript コンパイラを Go 言語でネイティブに書き直す計画を発表しました。そして2026年4月21日に、TypeScript 7.0のBeta版 のリリースが発表されました。

Go への移植により、ネイティブコードの速度と共有メモリによる並列処理を活かせるようになり、公式アナウンスでは TypeScript 6.x と比較して約10倍の高速化 が謳われています。 去年の発表以降、プレビュー版としてnpmパッケージ @typescript/native-preview はすでに公開されており、多くの大規模コードベースで既に実運用されているようで、ビルド時間の大幅削減が報告されています。

ゼストのリポジトリ構成

弊社のリポジトリは、11アプリ + 6パッケージのモノレポ構成で、全てTypeScriptで書かれています。 CIでは、PRごとに変更の影響範囲に応じて、関連するワークスペースの型チェックやテストなどが実行されています。

弊社ではtRPCとZodを多用しており、フロント側からもapiの型定義などを参照しています。 そのため型解決には非常に時間がかかる傾向があります。

長いものだと単体のCIだけで実行時間30分近いのものもあり、月間で約80,000分を消費しています。 小さな変更範囲のPRで、すぐにApproveをもらってもCI待ちでマージできない、なんて場面も多々ありました。

開発ワークフローとtsgo適用範囲

弊社ではgit-flowをカスタマイズしたブランチ戦略を採用しています。またリリース頻度は概ね週1回で、テスト期間を設けてから本番リリースするフローです。

  1. 通常の開発: feature ブランチから main に向けてPRを作成。マージされるとdev環境に自動デプロイ
  2. テスト期間: main から staging ブランチを切り出し、テスト期間中に発見された不具合は staging に向けてPRを作成・マージ
  3. リリース: stagingproduction のフローで本番デプロイ

今回、tsgoを適用するのは 1. の通常開発サイクルのCIのみ としました。 テスト期間中の不具合修正PR(staging向け)や本番デプロイ前のCIチェックでは、従来通りのtscを使います。 これはtsgoがまだベータ段階であり実際のビルド時はまだtscを使用しているため、リリースに近いフェーズでは安全性を優先する判断です。

tsgoを使える状態にするまで

CI最適化の前提として、まず各ワークスペースでtsgoによる型チェックが通る状態にしました。 前述の通りビルド時はtscを使い続けるため、既存の tsconfig.json には手を加えずに、tsgo専用の tsconfig.tsgo.json を並置する方針です。 TypeScript 7(tsgo)では baseUrltarget: ES5 が廃止されるなど一部の破壊的変更がありますが、tsgo用の設定ファイルを分離することで既存のtsc環境には一切影響を与えずに対応しました。

この状態でローカル環境で型チェックを実施したところ、tscと比べてなんと 3〜5倍の高速化 を確認できました。

流石に10倍とまではいかないものの、これは十分早くなっています。 実は過去にも何度か @typescript/native-preview を試してみたことがあったのですが、workspaceを使っている関係もあり当時はエラーが大量に出るなど問題が起きていました。 今回ベータリリースということでだいぶ修正されたみたいで、思いのほかスムーズにローカルで型チェックが通る状態にできました。

CIにtsgoを投入したら、ジョブが落ちた

ローカルでは快適に動いていたtsgoですが、GitHub Actions上で動かすとジョブが途中で突然キャンセルされる現象が発生しました。

$ tsgo -b tsconfig.tsgo.json && tsgo -b tsconfig.tsgo.json --clean
##[debug]Re-evaluate condition on job cancellation for step: 'Run type check'.
##[debug]Skip Re-evaluate condition on runner shutdown.
Error: The operation was canceled.

稀に成功するジョブもあるのですが、安定して成功させることができませんでした。 ログも特にエラーらしいエラーは出ておらず、ただ The operation was canceled. とだけ表示される状態でした。

最初の仮説:cancel-in-progress?

ワークフローには concurrency.cancel-in-progress: true が設定されていました。 これは新しいコミットがpushされたときに、既存のジョブをキャンセルすることでCIの待ち時間を減らすための設定です。 新しいコミットがpushされた場合、古いジョブがキャンセルされるのですが、今回は新しいコミットはpushされていないのにジョブがキャンセルされていました。

本当の原因:OOM(メモリ不足)

調べていくと、以下の事実が判明しました:

  1. NODE_OPTIONS="--max_old_space_size=8192" はtsgoに効かない(のに設定したままだった)
    • 元々tscの頃からメモリ不足だったため、NODE_OPTIONSの指定を入れていた
    • tsgoはGoバイナリのためこの環境変数は全く効果なし
  2. tsgoのメモリ消費はtscより大きい
    • microsoft/typescript-goのissueでも複数報告あり*1
  3. GitHub Actions プライベートリポジトリの標準ランナーは 2コア / 8GB RAM
    • ローカルマシンのスペックと大きく異なるため、ローカルで問題なくてもCIではOOMになる場合も

ピークRSSの計測

原因の裏付けのために、ローカルマシン(Apple Silicon, 32GB RAM)でtsgoのピークメモリ使用量を計測しました。macOSでは /usr/bin/time -l でプロセスの最大RSS(Resident Set Size)を取得できます。

/usr/bin/time -l yarn workspace <package-name> typecheck:tsgo 2>&1 \
  | grep -E "real|maximum resident|peak memory|Done"

結果:

Done in 42.28s.
Done in 42.65s.
       43.05 real        81.57 user        25.27 sys
          5498503168  maximum resident set size
            87246592  peak memory footprint

ピークRSSは約5.12 GiB(5,498,503,168 bytes)でした。ただしこの計測は --builders 1(並列ビルド制限)付きの条件です。制限なしの場合や、GOMEMLIMIT を設定していない場合は、これより大きなメモリを消費する可能性があります。

いずれにせよ、8GBのランナーではOS等で1〜1.5GBを消費するため、tsgoに使えるメモリは約6.5GB。実使用量が5GiBを超えている時点で余裕がほとんどなく、OOMが発生するのも頷けます。

最初の対策:GOMEMLIMIT + --builders 1

tsgoはGoバイナリなので、メモリ制御には Go の環境変数 GOMEMLIMIT を使います。 またtsgoのオプションに --builders というものがあり、これは並列ビルドプロジェクト数を制限するオプションです。 GoのGCはGOMEMLIMITに近づくと積極的にメモリ回収を行うため、並列度を下げることでメモリ使用量を抑えることができます。

環境変数/フラグ 効果
GOMEMLIMIT=XGiB Go GCがこの上限付近で積極的にメモリ回収(ソフトリミット)
--builders <n> 並列ビルドプロジェクト数を制限

全ワークフローに GOMEMLIMIT=6GiB + --builders 1 を設定して、再度実行してみました。

- name: Run type check
  env:
    GOMEMLIMIT: "6GiB"
  run: yarn workspace <package-name> typecheck:tsgo

すると、ジョブは安定して成功するようになりました。OOMで落ちる問題は解消されました。

しかし、ここで想定外の問題が発生します。

GOMEMLIMITの罠:tsgoがtscと同等の速度まで遅くなった

CIの実行ログを確認したところ、tsgoの型チェック時間がtscとほぼ変わらないことに気づきました。 ローカルで4〜5倍高速だったはずが、CIでは恩恵がほぼゼロです。 中にはむしろtsgoの方が遅いワークフローもありました。

元々CIの高速化のためにいくつかキャッシュなどを入れていたことや、そもそもCIの中では型チェック以外にもnpm installやlintなどの処理もあるため、単純に型チェック時間だけを比較するのは難しいのですが、それでもtsgoの型チェック時間がtscと同等になってしまうのは明らかにおかしいと感じました。

GOMEMLIMITの値を変えて計測

ローカルで GOMEMLIMIT の値を変えながら time コマンドで型チェック時間を比較しました。

# GOMEMLIMIT なし
time yarn workspace <package-name> typecheck:tsgo

# GOMEMLIMIT=6GiB
GOMEMLIMIT=6GiB time yarn workspace <package-name> typecheck:tsgo

# GOMEMLIMIT=12GiB
GOMEMLIMIT=12GiB time yarn workspace <package-name> typecheck:tsgo

結果:

条件 あるバックエンドアプリの型チェック時間
tsc(従来) 138秒
tsgo(GOMEMLIMIT なし, --builders 1) 31秒
tsgo(GOMEMLIMIT=12GiB) 30秒
tsgo(GOMEMLIMIT=6GiB, --builders 1) 117秒

GOMEMLIMIT=6GiB を設定すると、tsgoの速度が約4倍悪化。tscとほぼ同等になってしまいました。

なぜこうなるのか

tsgoの実使用メモリが約5.1GiBで、GOMEMLIMIT=6GiB だとその 85% に達します。GoのGCはGOMEMLIMITに近づくと積極的にメモリ回収を行うため、頻繁なGCが実行され、本来の型チェック処理が大幅に遅延します。

GOMEMLIMIT 実使用5.1GiBとの割合 GC圧力 影響
6 GiB 85% 非常に高い 約4倍遅くなった
7 GiB 73% 高い 顕著な遅延
8 GiB 64% 低い 軽微
12 GiB 43% ほぼゼロ 影響なし

実使用量の50%以下に設定すればGC圧力はほぼゼロ。70%を超えると目に見えて遅くなり、85%で約4倍遅くなるというのが実測から得られた知見です。

8GBランナーでのジレンマ

8GB RAMのランナーでは:

  • GOMEMLIMITを低く設定 → GC圧力でtsgoが遅くなり、導入の意味が消滅
  • GOMEMLIMITを設定しない → OOMでジョブが落ちる

8GBランナーではtsgoの恩恵を受けられないという結論に至りました。

ランナースペックの引き上げを検討

標準の8GBランナーではtsgoの恩恵を引き出せないため、より大きなランナーを検討しました。

GitHub Actionsでは標準ランナーより大きなスペックのカスタムランナーを利用できますが、スペックが上がるほど単価も上がります。

バランスを考えた結果、4コア / 16GB RAM のカスタムランナー($0.016/min)を採用しました。 GOMEMLIMIT=12GiB が設定できるため、tsgo本来の速度を引き出せます。

ちなみにさらに一つ上のクラス(8コア / 32GB等)にするとランナー単価の増加分が実行時間短縮の効果を上回り、トータルコストが増えてしまうという試算になりました。

最終構成

全ワークフローに以下の変更を適用しました:

通常PR向け(tsgo版)

jobs:
  test:
    runs-on:
      labels: <4-core-runner>   # 4コア / 16GB
    steps:
      - name: Run type check
        env:
          GOMEMLIMIT: "12GiB"  # 実使用の43%、GC圧力ほぼゼロ
        run: yarn workspace <package-name> typecheck:tsgo

stg/prod向けPR(tsc版、安全策)

tsgoはまだ開発中のため、stg/prod向けPRでは従来のtscを維持するワークフローを併設:

jobs:
  test:
    runs-on: ubuntu-latest    # 2コア / 8GB(tscなら十分。それでもだいぶカツカツだが…)
    steps:
      - name: Run type check
        env:
          NODE_OPTIONS: "--max-old-space-size=8192"
        run: yarn workspace <package-name> typecheck

変更のまとめ

項目 Before After
ランナー(testジョブ) 標準ランナー (2コア/8GB) カスタムランナー (4コア/16GB)
型チェッカー tsc tsgo
メモリ制御 NODE_OPTIONS GOMEMLIMIT: "12GiB"
--builders 1 あり 削除(メモリに余裕があるため不要)
Jest max-old-space-size 6144MB 8192MB
Jest workerIdleMemoryLimit 1024MB 2048MB

結果

計測条件

項目 内容
Before期間 2026-04-10 〜 04-21(tsc + 標準ランナー、8営業日)
After期間 2026-04-27 〜 05-12(tsgo + 4コアランナー、8営業日)
集計対象 各workflowの success / failure で完了したrun
所要時間 report-workflow-result ジョブの max(completed_at) - min(started_at)
除外 型チェック/テストジョブが skipped のrun(パスフィルタ非該当等)

※ tsgoの全面導入は 2026-04-24 のマージ。Before/After をこのタイミングで厳密に分割しています。

ワークフロー単位の実行時間と回数

ワークフロー Before 平均 After 平均 短縮率 Before run数 After run数
モバイルアプリ型チェック 8.6m 3.9m -54.7% 86 82
フロントエンド型チェック 8.9m 3.7m -58.5% 272 346
フロントエンドテスト 10.5m 5.5m -47.1% 198 273
外部API連携テスト 11.9m 5.9m -50.7% 196 299
パッケージA 12.2m 6.2m -48.9% 190 298
パッケージB 11.9m 6.5m -46.0% 201 304
パッケージC 12.4m 6.5m -47.5% 192 297
パッケージD 12.7m 6.2m -51.4% 191 305
パッケージE 8.2m 3.8m -53.3% 265 282
サブAPI テスト A 11.6m 6.1m -47.4% 192 301
サブAPI テスト B 12.2m 6.8m -44.2% 192 300
メインAPI テスト 22.7m 14.2m -37.4% 189 281

実行時間はすべてのワークフローで短縮し、平均で約-48%、最大で-58.5%(フロントエンド型チェック)の改善となりました。

なお実行回数は After 期間の方が多めですが、これはPR数やpushイベントの自然なばらつき(リリースサイクル・開発アイテム・連休前後の活動量など)の影響も含まれています。

コストについて

ランナー単価は $0.008/min → $0.016/min(2倍) になりましたが、ワークフロー単位の実行時間が約半分になったことで概ね相殺されており、トータルのコスト感としては 大きな変化はないと試算しています。 ただまだ最初の1ヶ月も経っていないので、今後の実行量や費用の遷移には引き続き注目していこうと思います。

開発体験への効果

数字以上に大きいのは CI待ち時間の体感です。

メインAPIテストは 22分 → 14分、フロントエンド型チェックは 9分 → 4分弱と、レビューしてもらってから「マージしていいよ」が出るまでの時間が体感で大きく改善しました。

そして何より大きいのは、ローカル上で実行するときです。 昨今のAI開発において並行して複数のタスクを行うことも多くなりましたが、型チェックなどはAIが自身の成果物に対して最低限の品質確認できる手段です。ここで時間がかかると気軽な変更ですらハードルになりかねないので、早く終わるのに越したことはありません。

得られた知見

1. tsgoはGoバイナリ — Node.jsの常識を捨てよ

NODE_OPTIONS は効きません。メモリ制御は GOMEMLIMIT、並列度は --builders で制御します。Node.js脳でアプローチすると最初の一歩で躓きます。

2. GOMEMLIMITは「スイートスポット」がある

実使用量の50%以下に設定すればGC圧力はほぼゼロ。70%超で顕著な遅延、85%で約4倍の劣化。設定すれば安心というものではなく、値を間違えると逆効果です。

3. tsgoのメモリ問題はアップストリームで進行中

tsgoはまだ開発中であり、メモリ効率の改善は今後のバージョンで進む可能性があります。 また、そもそもプロジェクト自体の型定義が複雑な所為で型解決に時間がかかっているという問題もあり、こちらも社内で改善中です。 その辺りが改善されれば、ゆくゆくは標準ランナーに戻せるかもしれません。

Claude Codeとの協業について

今回の作業はClaude Codeとの協業で進めました。 tsconfig.tsgo.jsonの設計や、各ワークスペースで発生した型エラーの解消、CIワークフローの修正など、コードを書く部分はほぼ任せています。

特に有り難かったのは、トラブルシュートのフェーズです。 「ジョブが理由不明でキャンセルされる」という曖昧な事象に対して、NODE_OPTIONSがGoバイナリには効かない可能性 に当たりをつけたり、ローカルでピークRSSを /usr/bin/time -l で計測することを提案してくれたり、GOMEMLIMIT を変えながら時間を測って GC 圧力の影響を可視化する手順を組み立ててくれたりと、仮説の立案と検証手順の組み立て で大きく前進しました。

一方で、microsoft/typescript-go のissueの調査は人間(自分)が手を動かしました。Claude Code は学習時点以降の最新情報や、特定リポジトリ内の議論の温度感までは追えないため、「Claude Codeが知らない情報や、リアルタイムの一次情報を補完する」のが、今のところの人間の役割だと感じています。 逆に言えば、その情報を渡せば一気に推論を回してくれるので、「AIに任せきり」でも「人間がゼロから全部やる」でもなく、お互いの得意分野を埋め合う形が現状の落とし所かなと思っています。

残る課題:Zodが重い

今回 tsgo の導入で型チェックは大幅に高速化できましたが、そもそも弊社のコードベース自体に「型解決を重くしている」要因が残っています。その代表が Zod です。

弊社リポジトリでは Zod v3 を全面的に使っており、ドメインモデル、tRPCのスキーマ、API の入出力定義など、ほぼあらゆる箇所で型起点として動いています。Zod は表現力が高い反面、v3 では複雑なスキーマで型推論が極端に重くなることが知られており、tsc / tsgo どちらにとっても無視できないボトルネックになっています。

ここで期待できるのが、Zod v4 です。 公式アナウンス*2 によると、tsc の型インスタンス化数が約20倍削減、parse 速度も string で約14倍、object で約6.5倍と、型解決・ランタイム実行の両面で大幅な高速化が報告されています。

弊社でも、現在まさに Zod v4 へのアップデート作業を進めているところです。 これが完了すれば、tsgo の効果と相まって型チェックがさらに高速化することが期待でき、場合によっては 標準ランナーで完走できる ような状態に戻せるかもしれません。

まとめ

tsgoのCI導入は「入れれば速くなる」という単純な話ではありませんでした。Go GCのメモリ特性、GitHub Actionsのランナースペック制約、コスト構造を理解した上で、適切なランナーとGOMEMLIMIT値を選定する必要があります。

最終的に、ワークフロー実行時間は平均で約半分まで短縮(短縮率-37%〜-58%)できました。 ランナースペックを引き上げた分の単価増は実行時間の短縮で相殺されており、コストはほぼ変わらず、純粋に時間だけが短くなった形です。レビュー後のマージまでの待ち時間が体感で大幅に減り、開発体験は大きく向上しました。