Type Aliasで実現する「型レベルのバリデーション」:Zodとの比較と使い分け
コードレビューをしていて、次のようなコードに出くわしたことはないでしょうか。
type JsonString = string; // 「JSON文字列であること」を祈っているだけの型
type Email = string; // 「@が含まれていること」を祈っているだけの型
残念ながら、これは型安全ではありません。TypeScriptの型システムにおいて、`string`はどこまでいっても`string`です。ランタイムに入れば、任意の不正な文字列がこの型名の下をすり抜けてビジネスロジックを破壊します。
「ランタイムバリデーションといえばZod」というのは、現代のフロントエンド開発において常識になりつつあります。しかし、すべての入力値や設定ファイルに対して重いランタイムスキーマを走らせる必要はあるでしょうか?
今回は、TypeScriptの型システム(Type Alias)の限界ギリギリまで攻め込み、「コンパイル時にバリデーションを完了させる」ための高度な設計パターンを、Zodとの比較を交えながら徹底解説します。
—
1. なぜ型レベルバリデーションなのか?(Zodのコストと限界)
Zodをはじめとするランタイムバリデーションライブラリは素晴らしい発明です。外部APIのレスポンスやユーザー入力など、「信頼できない境界(Trust Boundary)」において、実行時型安全性を担保するための唯一無二のツールです。
しかし、Zodには以下のトレードオフが存在します。
1. 実行時コスト: パース処理(`parse` / `safeParse`)はCPUサイクルを消費します。数千件のオブジェクト配列を毎フレーム処理するようなホットパスでは無視できないオーバーヘッドになります。
2. バンドルサイズ: クライアントサイドのバンドルサイズにシビアな環境では、バリデーターのコード量も無視できません。
3. 「型」と「値」の二重管理: スキーマ定義オブジェクトはランタイムの「値」であり、そこから`z.infer`で「型」を生成します。
ここで考えてみてください。「開発者がコードを書いた瞬間(コンパイル時)に、その文字列が特定の形式を満たしていることが静的に保証されているべきデータ」はないでしょうか? 例えば、データベースのUUID、特定のプレフィックスを持つID、環境変数名、あるいはドメイン固有のフォーマットを持つコードなどです。
これらをType Aliasとテンプレートリテラル型、そして条件付き型(Conditional Types)を駆使して表現するのが、「型レベルバリデーション」です。
—
2. 実装:Type Aliasによる厳格な型制約
TypeScript 4.1以降で導入されたテンプレートリテラル型と、`.d.ts`レベルの高度な型パースを活用し、コンパイル時に構造を検証するコードを見てみましょう。
以下のコードは、「`org_`から始まり、その後に英小文字とハイフンのみが続き、最後に数字のバージョンが続く文字列(例: `org_acme-corp-v1`)」という組織IDの形式を、ランタイムのコードを一切書かずに型レベルで強制・検証するプロダクションコードです。
/
- — 型レベルバリデーションの実装 —
/
// 1. 基本的な文字セットの定義
type LowercaseAlpha = ‘a’|’b’|’c’|’d’|’e’|’f’|’g’|’h’|’i’|’j’|’k’|’l’|’m’|’n’|’o’|’p’|’q’|’r’|’s’|’t’|’u’|’v’|’w’|’x’|’y’|’z’;
type Hyphen = ‘-‘;
type Digit = ‘0’|’1’|’2’|’3’|’4’|’5’|’6’|’7’|’8’|’9′;
// 2. 再帰的な文字列パースによる文字種チェック
type IsValidBody
T extends `${infer First}${infer Rest}`
? First extends LowercaseAlpha | Hyphen
? IsValidBody
: false
: true; // 空文字になったら終了
// 3. 組織IDの厳密なフォーマット定義(型関数)
type ValidateOrgId
T extends `org_${infer Body}v${infer Ver}`
? Body extends ”
? never
: IsValidBody extends true
? Ver extends `${Digit}`
? T
: never
: never
: never;
/
- 4. ブランテッドタイプ(Branded Types)との融合
- 単なるstringではなく、「検証済み組織ID」という独自の型レイヤーを付与する
/
declare const __brand: unique symbol;
export type ValidatedOrgId
? never
: T & { readonly [__brand]: ‘ValidatedOrgId’ };
/
- 5. コンパイル時アサーションを伴うスマートコンストラクタ
- 実行時には最小限のチェック、あるいは信頼できる定数からのアキャストを行う
/
export function createOrgId
// 実行時チェック(必要であれば行うが、型レベルで弾かれているためリテラルなら即座にわかる)
if (!/^org_[a-z-]+v[0-9]+$/.test(id)) {
throw new Error(`Invalid Organization ID format: ${id}`);
}
return id as ValidatedOrgId
}
このコードの何が凄まじいのか?
もし、開発者が次のようにコードを書いたとします。
// 【正常系】コンパイル通過
const validId = createOrgId(“org_acme-corp-v1”);
// 【異常系A】フォーマット違反(コンパイルエラーになる!)
// Type ‘string’ is not assignable to type ‘never’.
const invalidId1 = createOrgId(“INVALID_ID”);
// 【異常系B】大文字が含まれている(コンパイルエラー!)
const invalidId2 = createOrgId(“org_Acme-v1”);
開発者がタイポしたり、ルールに違反した文字列をリテラルとして渡した瞬間、IDE上でコンパイルエラーとして検知されます。Zodのパース関数を走らせるまでもなく、コードを書いている最中にミスが潰されるため、フィードバックループが圧倒的に高速化します。
—
3. Zodとの使い分けの境界線
「じゃあ、すべてのバリデーションをType Aliasに置き換えればいいのか?」というと、それは最悪の設計です。コンパイラに過剰な負担をかけ、ビルドが遅くなり、エラーメッセージが難解になります。
テクニカルリードとして、チームに徹底すべき使い分けの基準を明示します。
| 評価軸 | Type Aliasによる型レベルバリデーション | Zodなどのランタイムバリデーション |
| :— | :— | :— |
| データの出所 | ソースコード内にハードコードされたリテラル、設定ファイル、定数 | ユーザー入力(フォーム)、外部APIレスポンス、DBからの読み込み |
| 検証タイミング | コンパイル時(ビルド時 / IDE上) | 実行時(Runtime) |
| オーバーヘッド | ゼロ(実行時のコードは生成されない) | 実行時パースコストあり |
| エラー表現 | コンパイラのエラー(`Type ‘never’ is not assignable…`) | ユーザーフレンドリーなエラーメッセージ(`ZodError`) |
| 適したユースケース | ルートパス、独自ID体系、環境変数名、限られたステータス値 | フォームのバリデーション、APIスキーマ、JSONインポート |
黄金のルール
> 「静的な構造やルールはType Aliasで縛り、動的で予測不可能な外部境界はZodで殴れ」
例えば、Next.jsのルーティングパスや、社内デザインシステムのコンポーネントバリアント名などは、Type Aliasで網羅的にバリデーションするのが最適解です。一方で、ユーザーがフォームに入力するメールアドレスは、必ずZod(あるいはYupやValibot)でランタイム検証しなければなりません。
—
4. パフォーマンスとスケーラビリティの罠(注意点)
型レベルバリデーションを導入する際、シニアエンジニアが最も注意しなければならないのが「TypeScriptコンパイラの型 instantiation(インスタンス化)の深さとパフォーマンス劣化」です。
TypeScriptの型システムはTuring Complete(チューリング完全)であることが知られていますが、複雑な再帰型(今回の `IsValidBody` のようなもの)を多用しすぎると、IDE(VSCodeなど)のLanguage Serverがフリーズしたり、CI/CDでのビルド時間が劇的に跳ね上がったりします。
避けるべきアンチパターン
1. 数千文字に及ぶ巨大な文字列の型レベルパース:
JSONパーサーを完全な型レベルで実装するOSS等がありますが、実務のプロダクションコードでこれをやると、型推論のタイムアウト(`Type instantiation is excessively deep and possibly infinite`)を引き起こします。
2. 複雑すぎる条件付き型の入れ子:
ネストが5階層を超える条件付き型は、チームメンバーの認知負荷を高めるだけでなく、コンパイルのボトルネックになります。
—
5. まとめ
Type Aliasによる型レベルバリデーションは、単なる「型パズル」のオナニーではありません。「実行時コストをゼロにしつつ、ドメインの不変条件(Invariants)をコードベースの静的領域に閉じ込める」ための極めて強力なアーキテクチャパターンです。
Zodのようなランタイムバリデーションと適切に役割を分割し、コードの境界線をコントロールすること。それこそが、大規模なTypeScriptアプリケーションを破綻させずにスケールさせるための、チーフアーキテクトの知見です。
今日のコードレビューから、「ただの `string`」として放置されている重要な文字列がないか、見直してみませんか?