型は「後で付ける注釈」ではなく「最初に設計する仕様」です。良い型はテストの一部を肩代わりし、リファクタの安全網になります。
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の取り違えを型で防ぐ(正確性・セキュリティ)
UserId も OrderId もただの 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. interface と type どちらを使う?
A. オブジェクトの形は基本どちらでも可。ユニオン・マップ型・条件型・テンプレートリテラル型を使うなら type が必要です。チームで一貫していれば十分です。
Q. なぜ enum を避けるの?
A. 実行時コード生成・ツリーシェイク阻害・数値 enum の型穴があるためです。as const の文字列ユニオンが軽量・安全で、網羅性チェックとも噛み合います。
Q. 凝った型はコンパイルを遅くしませんか? A. します。深い再帰型や巨大な条件型は型インスタンス化コストが増えます。可読性とコンパイル速度を損なう型は「やりすぎ」のサインです。
Q. as const と satisfies の違いは?
A. as const はリテラルを固定(読み取り専用化)し、satisfies は型適合を検査しつつ推論を保ちます。両者は併用でき、{...} as const satisfies T で「固定かつ検査」が可能です。
まとめ:型は「最強の最初のテスト」である
型レベルプログラミングの本質は、難解な技ではなく「間違いを書けなくする設計」です。実行時に祈るのではなく、コンパイル時に防ぐ。
- 不正な状態を表現できない型を設計する(判別可能なユニオン)。
- **
assertNever(NeverError)**で分岐漏れをビルド時に検出する。 satisfies・ブランド型・テンプレートリテラル型で、広げず・取り違えず・形を縛る。- マップ型・条件型で型を導出し、重複(知識のズレ)を消す(DRY)。
- 境界で実行時検証し、型と検証を両輪で回す。
- やりすぎない。 読めて・速くて・安全な型だけが資産になる(KISS・YAGNI)。
型が堅いコードは、リファクタを恐れず、レビューが速く、本番で壊れにくい。これは開発速度と信頼性の両方への投資です。
金融・決済のように「正しさ」が絶対的に求められるシステムの型設計、あるいは既存コードの型安全化が必要な場合は、お気軽にご相談ください。 下記の事例では、as / any / enum を禁じ、NeverError で網羅性を保証する型規律のもと、冪等な決済とコミッション計算をチームで構築した過程を紹介しています。