ネストされたオブジェクトを走査し、オートコンプリート用のドット区切りのパスを構築する型を作成したとします。小さなテスト用オブジェクトでは完璧に動作します。しかし、実際のAPIペイロードに向けた途端、エディタがフリーズします。そして最終的に、TypeScriptはエラー TS2589: Type instantiation is excessively deep and possibly infinite. を吐き出します。

このメッセージは、コードが伝統的な意味での無限ループを含んでいることを意味するわけではありません。コンパイラが諦めたことを意味しています。計算を求めた型が、本当に境界のないものであったか、あるいは有限であっても、その評価がTypeScriptの内部制限を使い果たすほど巨大であったかのどちらかです。これが発生すると、コンパイラはIDEがフリーズする前に停止します。

TS2589 が発生する原因

再帰的な型が最も一般的な原因です。TypeScriptは型を熱心に評価するため、ユーティリティ型が(特に条件付きロジックを通じて)自分自身を呼び出し続けると、計算スタックが急速に増大します。通常、以下のような特定のシナリオでこの壁に突き当たります。

  • 再帰的な条件付き型: ベースケースに到達するまで、タプル、オブジェクト、または文字列テンプレートを繰り返しデストラクトするもの
  • 深くネストされたオブジェクトパス生成器: { user: { address: { street: string } } } のような構造を "user" | "user.address" | "user.address.street" のような文字列リテラルのユニオンに変換するもの
  • テンプレートリテラル型: 文字列を1文字ずつ、あるいはトークンごとに解析するもの
  • マップ型: 数十のキーと複数のレベルを持つオブジェクトに対して反復処理を行うもの
  • 条件付き型: 大きなユニオンに対して分配(distribute)され、全メンバーに対して作業負荷を密かに増大させるもの

ネストされたパスの例は、特に魅力的な解決策に見えます。フォームライブラリや状態管理ツールは、フィールド名に対してオートコンプリートを提供するために、型付きパスを提供することを好みます。浅いオブジェクトであれば、すべての有効なドットパスを文字列ユニオンとして生成するのは容易です。しかし、深く、あるいは幅の広いオブジェクトでは、そのユニオンは爆発的に増加します。TypeScriptは、すべての組み合わせを一度にワーキングメモリに保持しなければなりません。ある程度の深さに達すると、コンパイラは作業量が予算を超えつつあることに気づき、非常ブレーキをかけます。

解決策 1: 強制的な深さ制限を追加する

TS2589を解決する最も直接的な方法は、型が永遠に再帰できるかのように振る舞うのをやめることです。サーキットブレーカーとして機能する「深さカウンター」を導入します。

実際には、型が再帰するたびに減少する数値のジェネリックパラメータ(多くの場合、長さをカウントダウンするタプルとして表現されます)を追加することを意味します。カウンターがゼロになると、型はそれ以上掘り下げる代わりに、string のような広範なフォールバックを返します。ユーザーは最初の4、5レベルについては依然として正確なオートコンプリートを利用でき、これは実世界のオブジェクトの大部分をカバーしています。それ以降については、コンパイラは単に型を広げて(widen)処理を続行します。

このアプローチは、ユーティリティ型の正当性を意味のある形で損なうものではありません。単に「境界」を設けるだけです。コンパイラをクラッシュさせる型システムは、適切な深さの後に潔く譲歩する型システムよりも有用であるとは言えません。

解決策 2: 一度に1つのパスのみを検証する

あらかじめ考えられるすべてのパスを生成するのがコストが高すぎる場合は、契約(contract)を変更します。すべての有効な文字列の巨大なユニオンを生成する代わりに、「特定の1つの」文字列が有効なパスであるかどうかをチェックする型を作成します。

すべての英単語の辞書を作成することと、単一の単語の綴りが正しいかどうかを確認することの違いを考えてみてください。前者は巨大なデータ構造であり、後者は軽量なスキャンです。TypeScriptの用語で言えば、"user.address.street" | "user.settings.theme" | ... を生成する Paths<T> ユーティリティをエクスポートするのではなく、IsValidPath<T, "user.address.street"> のようなものをエクスポートします。コンパイラは、実際に渡されたパスのみを評価します。

この転換は、APIの設計方法を変えます。関数のシグネチャで文字列を受け取り、ジェネリック制約を使用してオブジェクトの形状に対してそれを検証する、といった設計が可能になります。開発者が誤ったパスを入力すればIDEは依然として警告を出しますが、コンパイラは型チェック中にすべての有効なパスのセットを実体化(materialize)する必要がなくなります。大きなオブジェクトの場合、パフォーマンスの差は劇的です。

進行を止めないためのクイック戦術

これら2つの構造的な修正以外にも、いくつかの小さな習慣によって再帰的な型が限界を超えるのを防ぐことができます。

  • 型パラメータをタプルで囲んで、ディストリビューション(分配)を防ぐ。 T extends Foo ? Bar : Baz のように、条件式の中で生の型パラメータをそのまま使うと、T がユニオン型の場合、そのすべてのメンバーに対してチェックが分配されます。もしそのユニオンに50個のメンバーがあれば、TypeScriptは50回の個別のインスタンス化を実行します。[T] extends [Foo] ? Bar : Baz と書くことで、条件式をユニオン全体に対して一度だけ評価できます。各ユニオンメンバーに対して個別に型をマッピングする必要がない場合は、常にこの手法を使用してください。

  • デバッグ中は入力を最小限に絞り込む。 TS2589が発生したら、プロダクションで使用しているオブジェクト型を、プロパティが2つでネストが1レベルしかない小さなスタブに置き換えてみてください。もしエラーが消えるなら、問題は構文ミスではなく、深さ(depth)や要素数(cardinality)にあることが確認できます。これにより、構造的には問題のないロジックを書き直してしまう無駄を防げます。

  • 公開APIの型を緩やかにする。 内部的には、極めて精密な型が必要なこともあるでしょう。しかし、外部に対しては、完璧さを追求することのコストが、それによって得られるメリットを上回ってしまうことがあります。少し広めのオートコンプリート型を提供することで、エディタでの2秒のラグを防げるのであれば、通常はその方が価値があります。緩い型を使用する場合は、ランタイムバリデーターと組み合わせて、テスト時に不正なパスを捕捉できるようにしておけばよいのです。

なぜTypeScriptはこの境界を強制するのか

TypeScriptは停止性問題を解決することはできません。再帰的な型が最終的に終了するのか、それとも永遠にループし続けるのかを判断できないのです。コンパイラ内で無限ループが発生するリスクを避けるため、TypeScriptは保守的なカットオフ(打ち切り)を強制します。時には、十分な時間があれば終了していたはずの型が、そのカットオフによって捕捉されてしまうこともあります。TS2589は、コンパイラが「後悔するよりは安全策をとる」ことを認めているサインなのです。

この制限を尊重することは、プロダクショングレードの型を書く上で不可欠な要素です。型定義はコンパイラ内で実行されるコードであり、実行コストの高いコードは実害をもたらします。実行時のコードが遅いとユーザー体験を損なうのと同様に、オートコンプリートが遅いと開発速度を損なうことになります。

真の教訓

TS2589は、あなたが型システムのプログラマーとして未熟であることを示すものではありません。むしろ、その型が一度に処理しすぎているという合図です。再帰を制限し、検証を遅延させ、不要なディストリビューションを防ぎましょう。高度な型の目的は、コンパイル時にあらゆる真実を証明することではなく、チームに対して高速で信頼できるツールを提供することです。ミリ秒単位でコンパイルでき、95%のケースをカバーする型は、理論的には完璧であっても言語サーバーをクラッシュさせてしまう型よりも、はるかに価値があります。