【実務・中級編】Mapped Typesを駆使した「型レベルのバリデーション」:Zodなしでスキーマを定義する – TypeScript コア・型システムの基礎解析バイブル

Zodに頼るな。TypeScriptの型システムで「スキーマ」を完全掌握する

フロントエンド開発の現場で、APIレスポンスの型定義に頭を悩ませることはないだろうか。`interface`を書いて、`optional`を付け忘れ、実行時に`undefined`でアプリがクラッシュする。あるいは、Zodのようなランタイムバリデーターを導入して安心しているが、型定義とバリデーションロジックの二重管理に疲弊している。

TypeScriptにおいて、型とは「コンパイル時に存在する静的なドキュメント」ではない。「プログラムが遵守すべき数学的制約そのもの」だ。

今回は、ランタイムライブラリに頼らず、TypeScriptのMapped TypesとConditional Typesを駆使して、型レベルでスキーマを強制する「守りの設計」を伝授する。

—

1. なぜ「ただの型定義」では不十分なのか

多くの開発者は、次のように型を定義する。

interface User {
id: string;
email: string;
role: ‘admin’ | ‘user’;
}

これは単なる「データの形」に過ぎない。しかし、実務では「このフィールドは特定の形式でなければならない」「このキーは必須だが、条件によっては読み取り専用にしたい」といったビジネスルール(制約)が伴う。これらを型システムに組み込むのが、真のTypeScriptエンジニアの流儀だ。

—

2. Mapped Typesによる「型レベルのバリデーション」

Mapped Typesは、既存の型を変換し、新たな型を生成するための最も強力なツールだ。これを使うと、オブジェクトの各キーに対して「型による制約」を動的に適用できる。

例えば、「すべての文字列プロパティは、メールアドレス形式(@を含む)でなければならない」という制約を強制するスキーマを作ってみよう。

/

  • 文字列が “@” を含むことを型レベルで判定するユーティリティ

/
type IsEmail = T extends `${string}@${string}` ? T : never;

/

  • Mapped Typesを用いた「制約付きスキーマ定義」

/
type EmailSchema = {
[K in keyof T]: T[K] extends string ? IsEmail : T[K];
};

// 使用例
interface UserProfile {
username: string;
email: ‘test@example.com’; // OK
invalidEmail: ‘invalid-string’; // 型エラー!
}

// 実際にこのスキーマを適用する
type ValidatedUser = EmailSchema;
// エラー: Type ‘invalid-string’ does not satisfy the constraint ‘IsEmail<"invalid-string">‘.

このアプローチの美しさは、「不正なデータがコンパイルを通過できない」という一点に尽きる。ランタイムの重たいバリデーションロジックを回す前に、開発者のIDE上で即座にレッドラインが引かれるのだ。

—

3. 実践:APIクライアントのための「厳格なプロパティ修飾」

実務で頻出する「読み取り専用」や「必須化」を動的に制御するパターンを紹介する。特定のオブジェクトにおいて、特定の条件を満たすキーだけを`readonly`にする設計だ。

type ReadonlyIfString = {
[K in keyof T]: T[K] extends string ? Readonly : T[K];
};

interface ApiConfig {
endpoint: string; // 文字列なので readonly になる
timeout: number; // そのまま
retries: number;
}

const config: ReadonlyIfString = {
endpoint: ‘/api/v1’,
timeout: 5000,
retries: 3,
};

// config.endpoint = ‘/new-path’; // コンパイルエラー: Cannot assign to ‘endpoint’

このように、Mapped Typesでプロパティを再帰的に走査し、特定の条件(`extends string`)に合致する場合のみ修飾子を適用する。これにより、ヒューマンエラーによる設定値の書き換えをコンパイルレベルで封じ込めることができる。

—

4. パフォーマンスと保守性への視点

「型を複雑にするとコンパイル速度が落ちるのではないか?」という懸念はもっともだ。しかし、これらは再帰的な深い型定義(Recursive Types)に比べれば、計算コストは極めて低い。

心に留めておくべき鉄則:
1. 複雑な型演算は Utility Types に分離せよ: ロジックがインラインにあると、再利用性が損なわれ、読み解きが困難になる。
2. `any`への逃げ道を断て: 型レベルのバリデーションを導入する際は、必ず厳格な`tsconfig`設定(`strict: true`)を前提とすること。
3. ドキュメントとして機能させる: 型定義自体が「このデータはどうあるべきか」という仕様書そのものになるように命名せよ。

—

結論:型は「守り」であり「武器」である

ZodやYupといったランタイムバリデーターは、外部入力(APIのJSONやフォーム入力)を扱う際には不可欠だ。しかし、アプリケーション内部のデータ構造の整合性までランタイムに委ねるのは、タイプ安全性を放棄しているに等しい。

今回紹介したMapped Typesによるアプローチは、コンパイル時に型安全性を担保し、実行時の不要なバリデーション処理を削減する。これは単なるコードの書き方ではなく、バグを未然に防ぐための「言語仕様レベルの防壁」だ。

次にコードを書くとき、`interface`をただ並べるのはやめよう。「このデータ構造を、型システムでどう制約できるか?」を問いかけてみてほしい。その瞬間から、あなたのコードは一段上の「堅牢性」を手に入れるはずだ。

タイトルとURLをコピーしました