メインコンテンツへスキップ

v10リファクタリングで学んだTypeScriptパフォーマンスの教訓

· 1分で読める
Sachin Raja
Sachin Raja
tRPC Core Team Member (alumni)
非公式ベータ版翻訳

このページは PageTurner AI で翻訳されました(ベータ版)。プロジェクト公式の承認はありません。 エラーを見つけましたか? 問題を報告 →

ライブラリ作者としての私たちの目標は、開発者仲間のために最高の開発者体験(DX)を提供することです。エラー発生までの時間を短縮し、直感的なAPIを提供することで、開発者の精神的負担を軽減し、最も重要なことに集中できるようにします。それは素晴らしいエンドユーザー体験の実現です。

tRPCが驚くべきDXを実現する原動力がTypeScriptであることは周知の事実です。TypeScriptの採用は現代のJavaScriptベースの体験を提供する上での標準となりましたが、型に関する確実性の向上にはトレードオフも存在します。

現在、TypeScriptの型チェッカーは遅くなる傾向があります(TS 4.9のようなリリースは期待が持てますが!)。ライブラリはコードベース内で最も高度なTypeScriptの技法を含むことがほとんどで、TSコンパイラを限界まで酷使します。そのため、私たちのようなライブラリ作者はこの負担に寄与しないよう注意深く配慮し、IDEが可能な限り高速に動作し続けるよう最善を尽くさなければなりません。

ライブラリパフォーマンスの自動化

tRPCがv9の段階にあるとき、大規模なtRPCルーターが型チェッカーに悪影響を与え始めているという開発者からの報告が見られ始めました。これはtRPCにとって新しい経験でした。なぜならv9開発フェーズで驚異的な採用が進んだからです。より多くの開発者がtRPCで大規模な製品を作るにつれ、いくつかの問題点が表面化したのです。

今は遅くないかもしれませんが、ライブラリが成長・変化するにつれてパフォーマンスに目を光らせることが重要です。自動化されたテストは、各コミットごとにライブラリコードをプログラム的にテストすることで、ライブラリ開発(そしてアプリケーション構築!)における莫大な負担を取り除きます。

tRPCでは、3,500のプロシージャと1,000のルーターを含むルーターを生成し、テストすることで、これを可能な限り保証しています。しかしこれはTSコンパイラが破綻する前にどこまで押し込めるかをテストするもので、型チェックにかかる時間を測定するものではありません。サーバー、バニラクライアント、Reactクライアントというライブラリの3つの部分をすべてテストします。なぜならそれぞれ異なるコードパスを持つからです。過去には、ライブラリの特定のセクションに限定されたリグレッションが発生したことがあり、予期せぬ動作が発生したときにテストがそれを明らかにしてくれました。(コンパイル時間の測定についてはさらなる改善を計画中です)

tRPCはランタイム負荷の大きいライブラリではないため、パフォーマンス指標は型チェックを中心に据えています。そのため私たちは以下の点に注意を払っています:

  • tscを使った型チェックの遅延

  • 初期ロード時間の長さ

  • TypeScript言語サーバーが変更への応答に時間がかかること

最後の点はtRPCが最も注意を払わなければならない部分です。開発者が変更後に言語サーバーの更新を待たされることが決してあってはなりません。これこそtRPCがパフォーマンスを維持すべき領域であり、優れたDXを享受できるようにするためです。

tRPCにおけるパフォーマンス改善の機会をどう発見したか

TypeScriptの正確性とコンパイラパフォーマンスの間には常にトレードオフがあります。どちらも他の開発者にとって重要な関心事であるため、型の記述方法には細心の注意を払わなければなりません。特定の型が「緩すぎる」ためにアプリケーションが深刻なエラーに遭遇する可能性はあるか?パフォーマンス向上はその価値があるか?

そもそも有意義なパフォーマンス向上が得られるのか?これは素晴らしい問いです。

TypeScriptコードにおけるパフォーマンス改善の機会を_どのように_見つけるかを見ていきましょう。PR #2716を作成したプロセスを追跡し、TSコンパイル時間を59%削減した事例を紹介します。


TypeScriptには組み込みのトレーシングツールがあり、型のボトルネックを見つけるのに役立ちます。完璧ではありませんが、現状で最良のツールです。

実際のアプリケーションでライブラリをテストするのが理想的です。tRPCの場合、多くのユーザーが使用する環境を模倣するために基本的なT3アプリを作成しました。

tRPCのトレース手順は以下の通りです:

  1. 例示アプリにライブラリをローカルリンクします。これによりライブラリコードを変更後、即座にローカルでテストできます。

  2. 例示アプリで次のコマンドを実行:

    sh
    tsc --generateTrace ./trace --incremental false
    sh
    tsc --generateTrace ./trace --incremental false
  3. trace/trace.jsonファイルが生成されます。このファイルはトレース分析アプリ(私はPerfettoを使用)またはchrome://tracingで開けます。

ここからが本番で、アプリケーションの型パフォーマンスプロファイルを分析できます。最初のトレース結果は次のようになりました: 型チェックに332msかかったsrc/pages/index.tsを示すトレースバー

バーが長いほど、その処理に時間がかかっていることを示します。スクリーンショットでは最上位の緑バーを選択しており、src/pages/index.tsがボトルネックであることを示しています。Durationフィールドには332msと表示されています - 型チェックにこれほど時間がかかるのは異常です!青いcheckVariableDeclarationバーは、コンパイラが1つの変数に大部分の時間を費やしたことを伝えています。 バーをクリックすると対象変数が特定できます: 変数の位置が275であることを示すトレース情報 posフィールドから変数のファイル内位置が判明します。src/pages/index.tsの該当位置を確認すると、原因がutils = trpc.useContext()であることがわかりました!

しかしなぜでしょう?単純なフックを使っているだけなのに!コードを見てみます:

tsx
import type { AppRouter } from '~/server/trpc';
const trpc = createTRPCReact<AppRouter>();
const Home: NextPage = () => {
const { data } = trpc.r0.greeting.useQuery({ who: 'from tRPC' });
const utils = trpc.useContext();
utils.r49.greeting.invalidate();
};
export default Home;
tsx
import type { AppRouter } from '~/server/trpc';
const trpc = createTRPCReact<AppRouter>();
const Home: NextPage = () => {
const { data } = trpc.r0.greeting.useQuery({ who: 'from tRPC' });
const utils = trpc.useContext();
utils.r49.greeting.invalidate();
};
export default Home;

表面的には特に問題なさそうです。useContextとクエリの無効化だけです。一見TypeScriptに負荷をかけるような処理には見えず、問題はより深い層にあることを示唆しています。この変数の背後にある型を確認しましょう:

ts
type DecorateProcedure<
TRouter extends AnyRouter,
TProcedure extends Procedure<any>,
TProcedure extends AnyQueryProcedure,
> = {
/**
* @see https://tanstack.com/query/v4/docs/framework/react/guides/query-invalidation
*/
invalidate(
input?: inferProcedureInput<TProcedure>,
filters?: InvalidateQueryFilters,
options?: InvalidateOptions,
): Promise<void>;
// ... and so on for all the other React Query utilities
};
export type DecoratedProcedureUtilsRecord<TRouter extends AnyRouter> =
OmitNeverKeys<{
[TKey in keyof TRouter['_def']['record']]: TRouter['_def']['record'][TKey] extends LegacyV9ProcedureTag
? never
: TRouter['_def']['record'][TKey] extends AnyRouter
? DecoratedProcedureUtilsRecord<TRouter['_def']['record'][TKey]>
: TRouter['_def']['record'][TKey] extends AnyQueryProcedure
? DecorateProcedure<TRouter, TRouter['_def']['record'][TKey]>
: never;
}>;
ts
type DecorateProcedure<
TRouter extends AnyRouter,
TProcedure extends Procedure<any>,
TProcedure extends AnyQueryProcedure,
> = {
/**
* @see https://tanstack.com/query/v4/docs/framework/react/guides/query-invalidation
*/
invalidate(
input?: inferProcedureInput<TProcedure>,
filters?: InvalidateQueryFilters,
options?: InvalidateOptions,
): Promise<void>;
// ... and so on for all the other React Query utilities
};
export type DecoratedProcedureUtilsRecord<TRouter extends AnyRouter> =
OmitNeverKeys<{
[TKey in keyof TRouter['_def']['record']]: TRouter['_def']['record'][TKey] extends LegacyV9ProcedureTag
? never
: TRouter['_def']['record'][TKey] extends AnyRouter
? DecoratedProcedureUtilsRecord<TRouter['_def']['record'][TKey]>
: TRouter['_def']['record'][TKey] extends AnyQueryProcedure
? DecorateProcedure<TRouter, TRouter['_def']['record'][TKey]>
: never;
}>;

ここでいくつかの要素を解析する必要があります。まずこのコードの動作を理解しましょう。

再帰型DecoratedProcedureUtilsRecordが存在し、ルーター内の全手続き(procedure)を走査しながら、invalidateQueriesのようなReact Queryユーティリティで「装飾」しています。

tRPC v10では古いv9ルーターをサポートしていますが、v10クライアントはv9ルーターの手続きを呼べません。そこで各手続きについてv9手続きかどうか(extends LegacyV9ProcedureTag)をチェックし、該当する場合は除外しています。これはTypeScriptにとって大きな負荷です...遅延評価されていない場合

遅延評価

ここでの問題は、TypeScriptがすぐに使用されないコードも含めて型システム内で評価していることです。私たちのコードはutils.r49.greeting.invalidateしか使用していないため、TypeScriptはr49プロパティ(ルーター)をアンラップし、次にgreetingプロパティ(プロシージャ)、最後にそのプロシージャのinvalidate関数を見つけるだけで済むはずです。そのコードでは他の型は必要ありません。すべてのtRPCプロシージャに対するReact Queryユーティリティメソッドの型を即座に見つけようとすることは、TypeScriptを不必要に遅くするだけです。TypeScriptはオブジェクトのプロパティの型評価を直接使用されるまで遅延させるため、理論上は上記の型は遅延評価されるはずです...よね?

しかし、これは厳密にはオブジェクトではありません。全体をラップする型が存在します:OmitNeverKeysです。この型は値がneverであるキーをオブジェクトから削除するユーティリティです。ここでv9プロシージャを剥ぎ取り、これらのプロパティがIntellisenseに表示されないようにしています。

しかしこれが巨大なパフォーマンス問題を引き起こします。私たちはTypeScriptにすべての型の値を評価させ、それらがneverかどうかをチェックすることを強制してしまったのです。

どう修正すればよいでしょうか?型に行わせることを減らすように変更しましょう。

遅延評価の導入

v10 APIがレガシーv9ルーターをより優雅に扱える方法を見つける必要があります。新しいtRPCプロジェクトが相互運用モードのTypeScriptパフォーマンス低下の影響を受けるべきではありません。

アイデアはコア型自体を再構成することです。v9プロシージャとv10プロシージャは異なる実体であるため、ライブラリコード内で同じ空間を共有すべきではありません。tRPCサーバーサイドでは、ルーター内の単一のrecordフィールドではなく(前述のDecoratedProcedureUtilsRecord参照)、異なるフィールドに型を格納する作業が必要でした。

v9ルーターがv10ルーターに変換される際、プロシージャをlegacyフィールドに注入するように変更しました。

変更前の型:

ts
export type V10Router<TProcedureRecord> = {
record: TProcedureRecord;
};
// convert a v9 interop router to a v10 router
export type MigrateV9Router<TV9Router extends V9Router> = V10Router<{
[TKey in keyof TV9Router['procedures']]: MigrateProcedure<
TV9Router['procedures'][TKey]
> &
LegacyV9ProcedureTag;
}>;
ts
export type V10Router<TProcedureRecord> = {
record: TProcedureRecord;
};
// convert a v9 interop router to a v10 router
export type MigrateV9Router<TV9Router extends V9Router> = V10Router<{
[TKey in keyof TV9Router['procedures']]: MigrateProcedure<
TV9Router['procedures'][TKey]
> &
LegacyV9ProcedureTag;
}>;

前述のDecoratedProcedureUtilsRecord型を思い出せば、ここでLegacyV9ProcedureTagを付加してv9v10のプロシージャを型レベルで区別し、v9プロシージャがv10クライアントから呼び出されないように強制していたことがわかります。

変更後の型:

ts
export type V10Router<TProcedureRecord> = {
record: TProcedureRecord;
// by default, no legacy procedures
legacy: {};
};
export type MigrateV9Router<TV9Router extends V9Router> = {
// v9 routers inject their procedures into a `legacy` field
legacy: {
// v9 clients require that we filter queries, mutations, subscriptions at the top-level
queries: MigrateProcedureRecord<TV9Router['queries']>;
mutations: MigrateProcedureRecord<TV9Router['mutations']>;
subscriptions: MigrateProcedureRecord<TV9Router['subscriptions']>;
};
} & V10Router</* empty object, v9 routers have no v10 procedures to pass */ {}>;
ts
export type V10Router<TProcedureRecord> = {
record: TProcedureRecord;
// by default, no legacy procedures
legacy: {};
};
export type MigrateV9Router<TV9Router extends V9Router> = {
// v9 routers inject their procedures into a `legacy` field
legacy: {
// v9 clients require that we filter queries, mutations, subscriptions at the top-level
queries: MigrateProcedureRecord<TV9Router['queries']>;
mutations: MigrateProcedureRecord<TV9Router['mutations']>;
subscriptions: MigrateProcedureRecord<TV9Router['subscriptions']>;
};
} & V10Router</* empty object, v9 routers have no v10 procedures to pass */ {}>;

これでOmitNeverKeysを削除できます。プロシージャが事前にソートされるため、ルーターのrecordプロパティ型にはすべてのv10プロシージャが含まれ、legacyプロパティ型にはすべてのv9プロシージャが含まれるようになります。巨大なDecoratedProcedureUtilsRecord型の完全な評価をTypeScriptに強制することがなくなりました。またLegacyV9ProcedureTagによるv9プロシージャのフィルタリングも不要になります。

結果はどうだったか?

新しいトレースはボトルネックが解消されたことを示しています: src/pages/index.tsの型チェックに136msかかったことを示すトレースバー

大幅な改善です!型チェック時間が332msから136msへと減少しました 🤯!大局的に見れば大したことないように思えるかもしれませんが、これは大きな勝利です。200msは一度きりなら小さいものですが、以下の点を考慮してください:

  • プロジェクト内に存在する他のTSライブラリの数

  • 現在tRPCを使用している開発者の数

  • 作業セッション中に型が再評価される回数

これらを考慮すると、200msという時間が積み上がって非常に大きな数字になります。

私たちは常にTypeScript開発者の体験向上の機会を探しています。tRPCに関連するものか、他のプロジェクトで解決すべきTSベースの問題かは問いません。TypeScriptについて話したい方は、Twitterで@s4chinrajaまでメンションしてください。

この記事の執筆を手伝ってくれたAnthony Shewと、レビューを担当してくれたAlexに感謝します!