🎄

TypeScript型パフォーマンスを覗く 〜Union × 条件型 × 再帰で起きること〜

に公開

この記事は レバテック開発部 Advent Calendar 2025 8日目の記事です。

1. はじめに

TypeScriptでは、tsserverというプロセスが裏側で動いていて、コードを編集するたびに変更されたファイルやその依存関係部分を型チェックします。

TypeScriptで開発するようになってから、エディタの補完が一瞬止まったり、型チェックが返ってこなくなる場面が何度かありました。特にモブプロ中に大きめのファイルをいくつも開いていると、tsserverが固まったように見えることがあり、最初は「VSCodeのタブの開きすぎ、アプリの開きすぎかな」くらいに考えていました。

ところが調べていくうちに、型そのものの構造がTypeScriptの計算量を大きく左右することが分かってきました。型はただの注釈ではなく、裏側でさまざまな評価処理が走っています。書き方次第で処理が重くなることがあるのですが、その理由は直感では分かりづらいと感じました。

この記事では、とくに負荷が増えやすい 「Union型」、「Distributive Conditional Types」、「再帰型」の3つに絞って、なぜ重くなるのかを整理していきます。

1-1. 型が評価される仕組み

型チェックの際には、型パラメータを実際の型に置き換えたり、条件型の条件式を評価したりと、細かい計算が行われます。

このとき重要になる考え方が型の具体化(instantiation)です。型パラメータを具体的な型に展開したり、条件型の分岐を評価したりするたびに、この具体化が発生します。この型の具体化こそが、型の評価・解決処理そのものであり、これが繰り返される回数こそが型チェックの計算量となります。

そして難しく感じたのは、型の複雑さと処理量が比例しないことです。一見シンプルに見える型でも、別の型と組み合わさることで思わぬ膨張が起こり、補完が返ってこなくなるほど重くなることがあります。

具体的にどのような書き方がinstantiationを増やし、型チェックを重くするのか。
次の章で、代表的な3つのパターンを順に整理します。

  • 大きなUnion
  • Distributive Conditional Types
  • 再帰型

2. 型が重くなる3つのパターン

2-1. Union型:単体では軽いが、組み合わせると負荷が増える

Union型はA | B | Cのように複数の候補を表す型です。見た目としては「候補が少し増えただけ」に見えますし、実際Union単体であればそこまで重くなりません。TypeScriptの型システムから見ても、ただ候補の一覧を持っているだけの段階では、それほど大きな負荷にはなっていないはずです。

まず、候補一覧を持つだけのシンプルなUnionだけの例を見てみます。

type EventName =
  | "auth.signup"
  | "auth.login"
  | "auth.logout"
  | "user.profile_view"
  | "user.profile_update"
  | "billing.charge_succeeded"
  | "billing.charge_failed";

次に、Unionとconditional types(T extends U ? X : Y)を組み合わせると、TypeScript は Union の各要素をひとつずつ評価します。以下は、イベント名からドメイン部分(auth / user / billing)だけを取り出す例です。

type EventName =
  | "auth.signup"
  | "auth.login"
  | "auth.logout"
  | "user.profile_view"
  | "user.profile_update"
  | "billing.charge_succeeded"
  | "billing.charge_failed";

// "auth.signup" → "auth"
// "billing.charge_succeeded" → "billing"
type EventDomain<T> =
  T extends `${infer Domain}.${string}` ? Domain : never;

// Unionの7要素それぞれについて EventDomain<...> が評価される
type Domains = EventDomain<EventName>;
// => "auth" | "user" | "billing"

ここでは、EventNameのUnionに対してEventDomain<...>が要素数ぶん(この例だと7回)評価されます。Unionの要素が増えれば増えるほど、この分配回数も増えていきます。この段階では、Unionの要素数(7個)に比例して評価が行われるため、計算量はおおむねO(n)にとどまっています。

次に、「イベント名 × 優先度」を組み合わせたいケースを考えてみます。各イベントに "high" | "medium" | "low" のような優先度を付けるコードになります。

type EventName =
  | "auth.signup"
  | "auth.login"
  | "auth.logout"
  | "user.profile_view"
  | "user.profile_update"
  | "billing.charge_succeeded"
  | "billing.charge_failed";

type Priority = "high" | "medium" | "low";

// EventName × Priority = 7 × 3 = 21パターンのUnionになる
type Combined = `${EventName}_${Priority}`;

type ParsedEvent<T> =
  T extends `${infer Ev}_${infer Pr}`
    ? { event: Ev; priority: Pr }
    : never;

// 21要素のUnionに対して、21回Conditionalが評価される
type Parsed = ParsedEvent<Combined>;

見た目としては、増えているのは次の二つだけです。

  • EventNameが7個
  • Priorityが3個

型システムが評価する時は、Combinedは7 × 3 = 21要素のUnionに展開されます。そして TypeScriptは、その21要素すべてに対してParsedEvent<...>を個別に評価します。

このように、表面的には少しUnionを足しただけでも、内部では「Union × Union」によって評価回数が掛け算のように増えていきます。こうした構造が積み重なると、型チェックや補完が徐々に重くなる理由になります。

2-2. Distributive Conditional Types:Unionに対して「自動的に分配される」仕組み

2-1では「Unionそのもの」と「Union同士の掛け算」が評価回数を増やすことを見ました。ここでは、もう一つ重要な要素であるDistributive Conditional Typesそのものの振る舞いに注目します。

Conditional Types(T extends U ? X : Y)には、「TがUnionのとき、自動的に分配される」という特徴があります。これをDistributive Conditional Types (DCT)と呼びます。

まず、小さな例で仕組みを確認します。

type Status = "success" | "error";

type IsSuccess<T> =
  T extends "success" ? true : false;

type Flags = IsSuccess<Status>;
// 実際には以下の2つに分配される:
// IsSuccess<"success"> | IsSuccess<"error">
// => true | false

こちらのコードでは、Status"success" | "error"のUnionであるため、IsSuccess<Status>を評価すると内部で次の2つに分解されます。

  • IsSuccess<"success">
  • IsSuccess<"error">

TypeScriptはUnionの要素ごとにconditionalを1回ずつ評価するというルールを持っています。このルールは便利で、Unionのパターンマッチングを書くときに役立ちます。

次に、「イベント名がどのドメインかを判定する」例を見てみます。

// 20個の要素を持つUnion
type EventName =
  | "auth.signup"
  | "auth.login"
  | "auth.logout"
  | "user.profile_view"
  | "user.profile_update"
  | "billing.charge_succeeded"
  | "billing.charge_failed"
  | "billing.refund_initiated"
  | "billing.refund_completed"
  | "notification.email_sent"
  | "notification.push_sent"
  | "notification.in_app_open"
  | "search.started"
  | "search.completed"
  | "settings.opened"
  | "settings.updated"
  | "profile.avatar_uploaded"
  | "profile.avatar_deleted"
  | "session.heartbeat"
  | "session.ended";

// billing ドメインのイベントかどうか判定したい
type IsBilling<T> =
  T extends `billing.${string}` ? true : false;

// 20要素のUnionすべてに IsBilling<T> が分配される
type BillingFlags = IsBilling<EventName>;

ここでのポイントは、EventNameに20個の候補があることです。
そして、IsBilling<EventName>を評価すると、TypeScriptは次のように「20回」判定を行います。

IsBilling<"auth.signup">
IsBilling<"auth.login">
...
IsBilling<"billing.charge_failed">
...
IsBilling<"session.ended">

つまり、conditionalをたった一行書いただけでも、Unionの要素数(今回の例では20)分の評価が走るということです。

ここまでconditionalとUnionを組み合わせた例を見てきました。T extends ...はTがUnionのとき、要素をひとつずつに分割して評価するため、計算量はUnionの要素数に比例します。(おおむねO(n)

今回の例は20個なのでまだ軽いですが、大規模コードでは100〜200要素規模のUnionが登場することもあります。開発者は「1行のconditionalを書いたつもり」でも、内部ではその要素数ぶんの判定がすべて実行されます。
ここがDistributive Conditional TypesとUnionの組み合わせの気づきにくいパフォーマンスの増幅ポイントです。

2-3. 再帰型 × Union で起きるユニオン爆発

ここまでで扱ってきたのは、「Unionをそのまま評価する」ケースでした。
次は、TypeScriptの「再帰」を組み合わせることで、評価の構造そのものが入れ子になり、線形では収まりにくい増え方が起きてくるパターンを見ていきます。

再帰型の典型例:オブジェクトの深さを走査する

まずは、シンプルな再帰型の例です。オブジェクトのプロパティを辿ってすべてのパス(Keyの階層)をUnionで列挙する型です。

// ネストされた設定オブジェクト
type Settings = {
  auth: {
    login: {
      redirect: string;
      enable2FA: boolean;
    };
    signup: {
      termsRequired: boolean;
    };
  };
  profile: {
    avatar: {
      maxSizeMB: number;
    };
  };
};

// すべてのプロパティパスを "auth.login.redirect" のように列挙する型
type PropPaths<T, Prefix extends string = ""> =
  T extends object
    ? {
        [K in keyof T & string]:
          PropPaths<T[K], `${Prefix}${K}.`>
      }[keyof T & string]
    : Prefix extends `${infer P}.`
      ? P
      : never;

type AllPaths = PropPaths<Settings>;

PropPaths<T>はkeyを辿りながら再帰的にパスを構築します。この構造では、キーが増えると再帰の分岐も増えるため、計算量が木構造的に膨らむ傾向があります。

Settingsの規模は小さいので問題ありませんが、よりネストが深いものに対してこの型を適用すると、型計算量が急増します。

再帰 × Union × Conditional が組み合わさるとどうなるか

次に、「イベント名」を再帰的に分解する例を考えます。

// "auth.login.success" → ["auth", "login", "success"] にしたい
type SplitEvent<T extends string> =
  T extends `${infer Head}.${infer Tail}`
    ? [Head, ...SplitEvent<Tail>]
    : [T];

type Example = SplitEvent<"auth.login.success">;
// => ["auth", "login", "success"]

SplitEvent<T>自体がConditional types(T extends ... ? ... : ...)になっているため、このconditionalがUnionに対しても分配されることになります。
この型単体であれば軽いのですが、Unionのイベント名に再帰型を適用すると話が変わります。

type EventName =
  | "auth.login.success"
  | "auth.logout"
  | "auth.token.refresh"
  | "profile.avatar.uploaded"
  | "profile.avatar.deleted";

type SplitAll = SplitEvent<EventName>;

ここではEventNameに5要素のUnionがあり、そのすべてに対してSplitEvent<T>が分配されます。しかし、SplitEvent<T>は「再帰しながらUnionを返す」型のため、要素数が深さに応じて増えていき、「Unionを返す再帰型」→「そのUnionに対してconditionalがまた分配」というループが発生します。

これが ユニオン爆発(Union Explosion) が起きる典型的な形です。

もう少し分かりやすい「爆発例」

以下は、再帰×Unionが絡んだときにUnionのサイズがどれだけ膨らむかを示す最小例です。

// "A" を "A" | "AA" | "AAA" | ... のように深さNまで展開する型
type RepeatA<N extends number, Acc extends string = ""> =
  Acc["length"] extends N
    ? Acc
    : RepeatA<N, `${Acc}A`>;

// 1〜5 を Union にする
type Depth = 1 | 2 | 3 | 4 | 5;

// Depth の5要素すべてに対して RepeatA<N> が実行される
type Result = RepeatA<Depth>;

ここでは、入力のUnionは 1 | 2 | 3 | 4 | 5 の5要素です。再帰の深さはそれぞれの数値に応じて変わり、出力されるUnionは "A" | "AA" | "AAA" | ... | "AAAAA" のようになります。

ただし、重要なのは最終的なUnionの見た目ではなく、そこに至るまでにTypeScriptが内部で行っている「具体化(instantiation)」の回数です。変数Depthの要素数の分だけ再帰が走り、再帰の各ステップでtemplate literalを構築します。

それがUnionとなり、そのUnionに対して次のステップでconditionalが分配される、という流れです。この構造が積み重なることで、型の実体化(instantiation)の回数が指数関数的に増えていくことがあります。

なぜ再帰×Unionは指数的に重くなりやすいのか?

理由はシンプルで、次の2つが掛け算になるからです。

  • Unionの要素数
  • 再帰の深さに応じた分岐数

再帰は「深さぶん処理をする」構造ですが、Unionは「要素数ぶん分配される」構造です。たとえば、「5個の候補を持つUnion × それぞれに対して最大5ステップの再帰 × 各ステップでtemplate literalを生成」といった多段の掛け合わせが発生します。

つまり単純な「再帰の深さ × Union の要素数」ではなく、「Union × 再帰 × 分配 × template literal」が重なった複雑な構造になるため、TypeScriptの型システムの中で指数的な増加パターンが生まれやすくなります。

ここまでをざっくりまとめると、次のようになります。

  • Unionは単体では軽い(おおむね O(n)
  • conditional はUnionに分配される(おおむね O(n)
  • Union × Union の掛け算で要素数が増える(O(n×m)
  • 再帰 × Union が絡むと、評価回数が指数的に増えることがある

つまり、再帰型は型レベルの表現力を広げる一方で、型の計算量を上げるという、両面性のある仕組みです。複雑な型がIDEを重くする原因の1つには、この「再帰 × Union × Distributive Conditional」の組み合わせにあります。


3. おわりに

TypeScriptの型はとても強力ですが、その分だけ計算量が増えやすく、とくに
Union × Distributive Conditional × 再帰型 が重なると、型チェックの負荷が急激に増えることを実感しました。

まだTypeScriptを使い始めて4ヶ月ほどですが、今回調べる中で、型の書き方によって開発体験(補完速度・型チェック速度)が大きく変わることが分かってきました。TypeScriptの型は便利である一方で、書き方次第で処理が重くなるという視点は、これから長く使ううえで重要だと感じています。

今回見てきたように、「型計算が増える構造」そのものを避けることが、開発体験を守るうえで効きそうだと感じています。その観点で見ると、次のような方針が実務での設計判断の目安になりそうです。

  • 分配が起きる場所でUnionを増やしすぎない
  • Distributiveが意図せず分配されないよう気を付ける
  • 再帰型は必要最小限にする
  • 複雑な制約はZodなどランタイム側に任せる

これらの対策はまだ十分に使いこなせていませんが、今後TypeScriptをより深く理解していくうえで身につけていきたい領域です。型の強さと開発体験のバランスを取りながら、実務でも活かせる知識にしていければと思います。

レバテック開発部

Discussion