TypeScript の型システムは、値とは別の世界で「計算」ができます。条件型や再帰を組み合わせれば、型の上に小さなインタプリタを作ることすら可能です。型パズルとして楽しいのは間違いないのですが、問題はチームで保守する実務のコードベースで、どこまでやるべきかです。
結論を先に言うと、型レベルプログラミングは「入出力の境界での型変換」に留めるのが費用対効果の分岐点です。それを超えて複雑な検証やロジックを型で表現し始めると、コンパイル速度・エラーメッセージの可読性・保守性が急速に悪化します。この記事では、実務で使える範囲と、越えてはいけない一線を、具体的な機能とコストで整理します。
対象は TypeScript 5 系(執筆時点の安定版、2026 年 6 月)です。ここで挙げる機能とエラーは、特記のない限りその範囲で確認できるものです。
先にまとめ
- 型レベルプログラミングの実用的な用途は、API レスポンスの整形・オブジェクトのキー変換・関数の戻り値推論など「境界の型合わせ」に集中します。
- 深い再帰は
Type instantiation is excessively deep and possibly infinite (ts2589)で止まります。末尾再帰の条件型なら緩和されますが(TypeScript 4.5 以降)、それでも上限はあり、当てにした設計は脆いです。 - 型が複雑になるほどコンパイルとエディタ補完が遅くなります。これは体感できる実コストで、大規模コードベースほど効きます。
- ランタイムの値(外部入力)は型では守れません。そこは Zodのようなランタイム検証に任せ、
z.inferで静的型へ橋渡しするのが定石です。
型レベルでできること(実務例つき)
まず「実務で元が取れる」レベルの機能を確認します。基礎は TypeScript Handbook の Conditional Typesが一次情報です。
条件型と infer は、型から型を取り出す道具です。ライブラリの戻り値やタプルの中身を取り出す場面で日常的に使います。
type ElementOf<T> = T extends readonly (infer U)[] ? U : never;
type A = ElementOf<string[]>; // string
type B = ElementOf<[1, 2, 3]>; // 1 | 2 | 3mapped types とキー再マッピング(as による、TypeScript 4.1 以降)は、既存の型を機械的に変換します。たとえば「全プロパティを取得する getter 型を生成する」といった、DRY に効く使い方ができます。
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type UserGetters = Getters<{ name: string; age: number }>;
// { getName: () => string; getAge: () => number }この 2 つが型レベルプログラミングの主力で、ここまでは「手書きの型定義を減らし、実装と型のズレを防ぐ」という明確な利益があります。
再帰と限界
型は再帰できますが、無限ループを防ぐためコンパイラが深さに上限を設けています。上限を超えると次のエラーで止まります。
// 深いネストを型で処理しようとすると…
// error ts(2589): Type instantiation is excessively deep and possibly infinite.TypeScript 4.5 で末尾再帰の条件型に対する最適化が入り、末尾再帰の形に書ければかなり深い再帰まで扱えるようになりました(TypeScript 4.5 リリースノートを参照)。ただし、これは「無制限になった」わけではなく上限が引き上がっただけです。非末尾再帰の型はずっと浅いところで ts2589 に当たります。
実務上の教訓はシンプルで、「再帰の深さに依存した型は、入力次第で壊れる」ということです。長さ不定の文字列や任意ネストの JSON を型で完全に処理しようとすると、深さ制限やコンパイル時間の壁に必ず突き当たります。そこは型で頑張らず、後述のランタイム検証に逃がすのが安全です。
コンパイル速度という現実的コスト
型レベルの複雑さは、目に見えないコストとして開発体験に跳ね返ります。具体的には次の 2 つです。
tscのビルド時間:型計算が重いと型チェックが遅くなります。CI の型チェックが数十秒〜分単位で伸びることがあります。- エディタの補完・エラー表示の遅延:TypeScript 言語サーバーが型を評価するため、補完がもたつく、赤線が出るまで数秒かかる、といった形で毎日の作業に効きます。
これらは「動くけれど遅い」という形で表面化するため見落とされがちですが、大きなコードベースほど無視できません。tsc --generateTrace などで重い型を特定できますが、そもそも重い型を書かないのが最善の最適化です。凝った型を書いたら、補完のレスポンスが悪化していないかを体感で確認する癖をつけると安全です。
型で守れないもの:Zod との役割分担
型レベルプログラミングの最大の誤解は、「型で堅くすれば安全」という感覚です。TypeScript の型はコンパイル時にしか存在せず、実行時には完全に消えます。したがって、API レスポンス・フォーム入力・環境変数のような外部からの値は、型だけでは一切守れません。
ここは Zod のようなランタイムスキーマ検証の領域です。スキーマを 1 つ書けば、実行時の検証と静的型の両方が手に入ります。
import { z } from "zod";
const Post = z.object({
title: z.string(),
tags: z.array(z.string()),
});
// ランタイム検証(境界で必ず通す)
const post = Post.parse(await res.json());
// 静的型はスキーマから導出(二重管理しない)
type Post = z.infer<typeof Post>;役割分担の原則は「境界はランタイムで検証し、内部は静的型で回す」です。型レベルで複雑な検証ロジックを組むより、境界に Zod を置くほうが、実行時の安全性・コンパイル速度・可読性のすべてで有利になることが多いです。
型で解く前に:satisfies で足りることも多い
複雑なジェネリクスに手を出す前に、まず検討したいのが satisfies 演算子(TypeScript 4.9 以降)です。「値がある型に適合していることを検査しつつ、変数の型は書いたリテラルのまま(より具体的なまま)保つ」という、実務で頻出のニーズにちょうど噛み合います。
type Route = `/${string}`;
const routes = {
home: "/",
post: "/posts",
} satisfies Record<string, Route>;
// 検査は効くが、routes.home の型は string ではなく "/" のままRecord<string, Route> として型注釈すると値の具体性が失われますが、satisfies なら「制約は満たしているか」を確認したうえでリテラル型を維持できます。凝った mapped types を書く前に、satisfies や as const で目的が達成できないかを先に確かめると、型の複雑さを持ち込まずに済みます。詳細は TypeScript 4.9 リリースノートにあります。
実務での線引き
最後に、型レベルプログラミングを「やって良い / 避ける」の目安を表にまとめます。
| ケース | 判断 | 理由 |
|---|---|---|
戻り値・要素型の抽出(infer) | 使う | 低コストで実装と型のズレを防げる |
| キー変換・部分型生成(mapped types) | 使う | 手書き型定義の重複を減らせる |
| 固定パターンの文字列組み立て | 条件つきで使う | 便利だが深追いは補完を重くする |
| 任意ネスト・可変長の完全な型付け | 避ける | 深さ制限とコンパイル速度に必ず当たる |
| 外部入力の検証 | 型でやらない | ランタイムに存在しない。Zod 等に任せる |
判断軸は「その型は誰かが読んで直せるか」と「補完が遅くなっていないか」の 2 つで十分です。この 2 つが怪しくなったら、型で解くのをやめる合図です。
まとめ
TypeScript の型システムは強力で、実際に型の上で計算もできます。しかし実務での価値は、境界の型変換という地味な用途に集中しています。深い再帰やランタイム検証の代替として型を酷使すると、深さ制限・コンパイル速度・可読性という三方向のコストが返ってきます。
型は宇宙のように広いですが、住むのは地表で十分です。境界を Zod で固め、内部を素直な静的型で回す——この線引きが、型パズルの楽しさと保守性を両立させる現実解になります。
- 向いている人:ライブラリ作者、ユーティリティ型を整備したい人、実装と型のズレを型で潰したい人。
- 避けたほうがよい場面:アプリケーションコードで任意入力を型だけで検証しようとするケース。そこはランタイム検証の仕事です。