Chuyển đến nội dung chính
友田 陽大
Type safety & validation
TypeScript
フロントエンド
アーキテクチャ設計
Next.js
セキュリティ
React

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

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

Published
Reading time
10 min read
Author
友田 陽大
Chia sẻ

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


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

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

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

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

// ✅ 取り得る状態だけを列挙。矛盾は型レベルで作れない
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 型を使った網羅性チェックです。

// 網羅漏れを「コンパイルエラー」に変える番人
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 演算子です。

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の取り違えを型で防ぐ(正確性・セキュリティ)

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

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. テンプレートリテラル型:文字列の「形」を型にする

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

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 違反です。既存の型から派生させます。

// マップ型+キー再マッピング: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 で、特定の引数からの型推論を止めることができます。

// 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 など)を行い、その結果から型を導出します。

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 実践ガイドで扱っています。any で受けて奥まで流すのは、この境界を放棄する行為です。


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

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

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. interfacetype どちらを使う? A. オブジェクトの形は基本どちらでも可。ユニオン・マップ型・条件型・テンプレートリテラル型を使うなら type が必要です。チームで一貫していれば十分です。

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

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

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


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

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

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

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

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

友田

友田 陽大

Người phát triển sản phẩm đoạt Giải thưởng Bộ trưởng (METI). Với TypeScript + Python + AWS, tôi một mình triển khai trọn gói SaaS, chuyển đổi số công nghiệp và AI tạo sinh (RAG) sẵn sàng cho production — từ phân tích yêu cầu đến hạ tầng và vận hành.

Got a challenge?

From design to implementation and operations — solo × generative AI

Implementation like this article's, end to end from requirements to production. Start with a free 30-minute technical consult and tell me about your situation.

Nhận cả dự án trọn gói lẫn vai trò cố vấn kỹ thuật. Hãy bắt đầu bằng buổi tư vấn miễn phí 30 phút.

最短ルート:カレンダーから直接予約

相談内容が固まっている方は、フォーム送信よりその場で日程を確定する方がスムーズです。下記から空き時間をお選びください。

  • 30分のオンライン無料相談
  • Google Meet / Zoom / Microsoft Teams
  • NDA 商談前締結可・無理な営業はいたしません
無料相談の空き枠を予約する

Cũng đáng đọc