# TypeScript 型レベルプログラミング実践【2026年版】— 不正な状態を型で消し、本番品質を支える

> TypeScript の型レベルプログラミングを「実務で効く」範囲に絞って解説する完全ガイド。判別可能なユニオンと網羅性チェック（NeverError）、satisfies、ブランド型、テンプレートリテラル型、マップ型、条件型、NoInfer、型テストまで、可読性とコンパイル速度を保ちつつ不正な状態を型で排除する実践手法を実コードで示します。

- 公開日: 2026-06-24
- 著者: 友田 陽大
- タグ: TypeScript, フロントエンド, アーキテクチャ設計, Next.js, セキュリティ, React
- URL: https://tomodahinata.com/blog/typescript-type-level-programming-practical-guide

## 要点

- 型レベルプログラミングの目的は不正な状態を表現できなくすること（Make illegal states unrepresentable）
- 最も費用対効果が高いのは判別可能なユニオン＋assertNever（NeverError）で分岐漏れをコンパイル時に検出すること
- satisfies・ブランド型・テンプレートリテラル型が実務の三種の神器で、型を広げず検証しIDの取り違えを防ぐ
- 型は実行時に消えるため、外部入力は境界で Zod 等の実行時検証を行い、その結果から型を導出する
- 解読不能な型パズルはコンパイルを遅くし保守性を壊すので、読めて速くて安全な範囲に留める（KISS・YAGNI）

---

型は「後で付ける注釈」ではなく「最初に設計する仕様」です。良い型はテストの一部を肩代わりし、リファクタの安全網になります。

---

## 1. 設計思想：不正な状態を表現できなくする

多くのバグは「あり得ないはずの状態」が型上は作れてしまうことから生まれます。たとえば次の型は、ローディング中なのにデータもエラーもある、という矛盾を許します。

```ts
// ❌ 矛盾した状態を表現できてしまう
interface State {
  isLoading: boolean;
  data?: User;
  error?: Error;
}
// isLoading=true かつ data あり、のような不正状態が型上は合法
```

判別可能なユニオン（discriminated union）にすると、**取り得る状態だけ**を型で表現できます。

```ts
// ✅ 取り得る状態だけを列挙。矛盾は型レベルで作れない
type State =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: User }
  | { status: "error"; error: Error };
```

`status` が `"success"` のときだけ `data` が存在する、と型が保証します。`if (state.status === "success")` の中では `state.data` が安全に使え、それ以外では存在すらしません。これがすべての出発点です。

---

## 2. 網羅性チェック：`assertNever`（NeverError）で分岐漏れを潰す

判別可能なユニオンの真価は、**新しい状態を追加したときに「対応し忘れた箇所」をコンパイラが教えてくれる**ことです。鍵は `never` 型を使った網羅性チェックです。

```ts
// 網羅漏れを「コンパイルエラー」に変える番人
class NeverError extends Error {
  constructor(value: never) {
    super(`Unreachable: unexpected value ${JSON.stringify(value)}`);
  }
}

function render(state: State): string {
  switch (state.status) {
    case "idle":
      return "待機中";
    case "loading":
      return "読み込み中…";
    case "success":
      return `ようこそ、${state.data.name} さん`;
    case "error":
      return `エラー: ${state.error.message}`;
    default:
      // ここに来ると state は never 型のはず。
      // 新しい status を足して case を書き忘れると、ここで型エラーになる。
      throw new NeverError(state);
  }
}
```

`State` に `{ status: "refreshing" }` を追加した瞬間、この `switch` がコンパイルエラーになり、対応漏れが**ビルド時に**発覚します。実行して初めて気づくのではなく、書いた瞬間に気づける——これが型レベル設計の実利です。

> なぜ `enum` ではなくユニオン型か：`enum` は実行時にオブジェクトを生成し、ツリーシェイクされにくく、数値 enum は型安全性に穴があります。`as const` の文字列ユニオンは、より軽量で安全、かつ網羅性チェックと相性が良いため、実務では `enum` を避けてユニオンに寄せる規律が有効です。

---

## 3. `satisfies`：型を広げずに検証する

「型に適合しているか検査したいが、推論される具体的な型（リテラル）は失いたくない」——この矛盾を解くのが `satisfies` 演算子です。

```ts
type Theme = "light" | "dark";

// ❌ 型注釈だと値が Record<Theme,string> に広がり、キー補完が効かない
const colorsA: Record<Theme, string> = { light: "#fff", dark: "#000" };

// ✅ satisfies なら「Theme を網羅しているか」を検査しつつ、
//    実際の型は { light: string; dark: string } のまま保たれる
const colors = {
  light: "#fff",
  dark: "#000",
} satisfies Record<Theme, string>;

colors.light; // string（具体的なキーで補完が効く）
// colors.blue は存在しない → コンパイルエラー（網羅・余剰の両方を検査）
```

設定オブジェクトやルーティングテーブルなど、「形は守りたいが具体値も活かしたい」場面で多用します。`as` による危険なキャストの大半は、`satisfies` で安全に置き換えられます。

---

## 4. ブランド型：IDの取り違えを型で防ぐ（正確性・セキュリティ）

`UserId` も `OrderId` もただの `string` だと、引数の順番を間違えてもコンパイルが通ってしまいます。決済や認可のように取り違えが事故に直結する領域では、これは致命的です。ブランド型（nominal typing の擬似実装）で区別します。

```ts
declare const brand: unique symbol;
type Brand<T, B extends string> = T & { readonly [brand]: B };

type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;

// 生成は検証を通った1か所だけに集約する（境界での型付け）
function toUserId(raw: string): UserId {
  if (!/^usr_/.test(raw)) throw new Error("invalid user id");
  return raw as UserId; // ここだけは as を許す（検証済みの単一の入口）
}

function refund(order: OrderId, user: UserId) {/* ... */}

const u = toUserId("usr_123");
const o = "ord_999" as OrderId;
refund(u, o); // ✅
// refund(o, u); // ❌ 引数の取り違えがコンパイルエラーになる
```

「素の `string` を関数の奥まで流さない」だけで、ID 取り違え・通貨混同・未検証入力の混入といった一群のバグを型で締め出せます。`as` を使うのはこうした「検証済みの単一の入口」に限定するのが規律です。

---

## 5. テンプレートリテラル型：文字列の「形」を型にする

文字列の構造（ルート、イベント名、キー）を型で縛れます。

```ts
type Route = `/blog/${string}` | `/case-studies/${string}` | "/";
const go = (r: Route) => {/* ... */};
go("/blog/typescript"); // ✅
// go("/blgo/typo");     // ❌ 形が違うとエラー

// キー変換と組み合わせて API を導出
type EventName<T extends string> = `on${Capitalize<T>}`;
type ClickHandler = EventName<"click">; // "onClick"
```

タイポしやすい文字列を型で守ると、リファクタ時の安全性が一段上がります。

---

## 6. マップ型と条件型：型から型を導出する（DRY）

同じ情報を複数の型に手で書くのは DRY 違反です。既存の型から派生させます。

```ts
// マップ型＋キー再マッピング：T からゲッター型を自動生成
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 }

// 条件型＋infer：配列要素型や戻り値型を抽出
type ElementOf<T> = T extends readonly (infer U)[] ? U : never;
type Item = ElementOf<string[]>; // string
```

「元の型」を1か所変えれば派生型すべてが追従します。型定義の重複は、知識の重複であり、ズレの温床です。

---

## 7. ジェネリクスと `NoInfer`：推論を制御する

ジェネリクスは「型を引数化」して再利用性（ETC）を上げます。さらに TypeScript 5.4 の `NoInfer` で、**特定の引数からの型推論を止める**ことができます。

```ts
// options.default の型から T が推論されるのを防ぎ、第1引数を真実とする
function createState<T>(initial: T, options: { default: NoInfer<T> }) {/* ... */}

createState<"a" | "b">("a", { default: "b" }); // ✅
// createState("a", { default: "c" });          // c が T を汚染しない＝意図通りエラーにできる
```

これは「複数の引数のうち、どれを推論の基準にするか」を設計者が制御するための道具です。ライブラリ的な API を作るときに効きます。

---

## 8. 実行時境界との接続：型は「内側」、検証は「入口」

最重要の原則です。**型は実行時には消えます。** 外部入力（API レスポンス・フォーム・環境変数）は、型注釈だけでは守れません。境界で実行時検証（Zod など）を行い、その結果から型を導出します。

```ts
import { z } from "zod";

const UserSchema = z.object({ id: z.string(), name: z.string() });
type User = z.infer<typeof UserSchema>; // スキーマから型を導出（単一の真実）

async function getUser(raw: unknown): Promise<User> {
  return UserSchema.parse(raw); // ここで初めて型が「保証」される
}
```

型レベルの設計（内側の正しさ）と実行時検証（境界の安全）は両輪です。詳しくは[React Hook Form × Zod 実践ガイド](/blog/react-hook-form)で扱っています。`any` で受けて奥まで流すのは、この境界を放棄する行為です。

---

## 9. 型をテストする・壊さない

型も資産である以上、退行を防ぐテストが要ります。

```ts
import { expectTypeOf, test } from "vitest";

test("State の success は data を持つ", () => {
  expectTypeOf<Extract<State, { status: "success" }>>().toHaveProperty("data");
});

// 「コンパイルエラーになるべき」ことのテスト
// @ts-expect-error budget は許可された値のみ
const bad: Theme = "blue";
```

`@ts-expect-error` は「ここはエラーになるべき」という意図を固定します（誤って通るようになったら、逆にこの行がエラーになって気づけます）。

---

## 10. アンチパターン（やりすぎ・危険）

- ❌ **`any` で受けて奥まで流す。** 型安全が連鎖的に崩壊する。`unknown` で受けて境界で絞る。
- ❌ **安易な `as` キャスト。** 嘘を型システムに教える行為。`satisfies` やブランド型生成関数に置き換える。
- ❌ **`enum` を多用する。** ユニオン＋`as const` の方が軽量・安全・網羅性チェック向き。
- ❌ **解読不能な再帰型・条件型の入れ子。** コンパイルが遅くなり保守不能に（KISS・YAGNI 違反）。型は「読める」範囲で。
- ❌ **網羅性チェックを省く。** `default` で握りつぶすと、状態追加時の漏れに気づけない。`assertNever` を置く。
- ❌ **型と実行時検証を混同する。** 型は境界を守らない。外部入力は必ず実行時に検証する。

---

## 11. FAQ（よくある質問）

**Q. 型レベルプログラミングは難しそう。どこから始める？**
A. まず「判別可能なユニオン＋`assertNever`」だけで十分に元が取れます。次に `satisfies`、ブランド型、と必要になった順に足してください。最初から高度な条件型は不要です（YAGNI）。

**Q. `interface` と `type` どちらを使う？**
A. オブジェクトの形は基本どちらでも可。ユニオン・マップ型・条件型・テンプレートリテラル型を使うなら `type` が必要です。チームで一貫していれば十分です。

**Q. なぜ `enum` を避けるの？**
A. 実行時コード生成・ツリーシェイク阻害・数値 enum の型穴があるためです。`as const` の文字列ユニオンが軽量・安全で、網羅性チェックとも噛み合います。

**Q. 凝った型はコンパイルを遅くしませんか？**
A. します。深い再帰型や巨大な条件型は型インスタンス化コストが増えます。可読性とコンパイル速度を損なう型は「やりすぎ」のサインです。

**Q. `as const` と `satisfies` の違いは？**
A. `as const` はリテラルを固定（読み取り専用化）し、`satisfies` は型適合を検査しつつ推論を保ちます。両者は併用でき、`{...} as const satisfies T` で「固定かつ検査」が可能です。

---

## まとめ：型は「最強の最初のテスト」である

型レベルプログラミングの本質は、難解な技ではなく「**間違いを書けなくする設計**」です。実行時に祈るのではなく、コンパイル時に防ぐ。

1. **不正な状態を表現できない型**を設計する（判別可能なユニオン）。
2. **`assertNever`（NeverError）**で分岐漏れをビルド時に検出する。
3. **`satisfies`・ブランド型・テンプレートリテラル型**で、広げず・取り違えず・形を縛る。
4. **マップ型・条件型**で型を導出し、重複（知識のズレ）を消す（DRY）。
5. **境界で実行時検証**し、型と検証を両輪で回す。
6. **やりすぎない。** 読めて・速くて・安全な型だけが資産になる（KISS・YAGNI）。

型が堅いコードは、リファクタを恐れず、レビューが速く、本番で壊れにくい。これは開発速度と信頼性の両方への投資です。

**金融・決済のように「正しさ」が絶対的に求められるシステムの型設計、あるいは既存コードの型安全化が必要な場合は、お気軽にご相談ください。** 下記の事例では、`as` / `any` / `enum` を禁じ、`NeverError` で網羅性を保証する型規律のもと、冪等な決済とコミッション計算をチームで構築した過程を紹介しています。
