バリデーション設計は型だけに任せない
TypeScript バリデーション設計でまず押さえたいのは、型と実行時検証を混同しないことです。
TypeScriptの型は、開発中の不整合を見つける強い仕組みです。しかし、ユーザー入力、URLパラメータ、localStorage、APIレスポンスは実行時に外から入ってきます。そのため、型注釈だけでは不正な値を防げません。
この記事では、TypeScript実務でバリデーションをどこに置くかを整理します。目的は、画面ごとの場当たり的なチェックを減らし、レビューしやすい境界を作ることです。
TypeScriptの型とバリデーションの役割を分ける
まず、TypeScriptの型はコンパイル時の約束です。一方で、バリデーションは実行時の値を確認する処理です。
例えば、次のコードは見た目には安全そうです。ただし、実際のAPIレスポンスが壊れていても、型注釈だけでは検出できません。
type User = {
id: string;
email: string;
age: number;
};
async function fetchUser(): Promise<User> {
const response = await fetch("/api/user");
return response.json();
}
この実装では、`age`が文字列でもコンパイルは通ります。つまり、外部入力に型を付けただけでは、値の正しさは保証されません。
そのため、API境界では一度`unknown`として受け取り、検証後にアプリ内部の型へ変換する設計が有効です。TypeScriptの絞り込みについては、TypeScript HandbookのNarrowingも参考になります。
バリデーション設計で分けたい4つの責務
実務では、すべてのチェックを1か所に寄せると読みにくくなります。そこで、責務を4つに分けて考えます。
| 場所 | 主な責務 | 置きすぎると起きる問題 |
|---|---|---|
| UI | 入力しやすさ、即時フィードバック | 業務ルールが画面に散らばる |
| スキーマ | 型、形式、必須、範囲の検証 | 画面固有の都合まで混ざる |
| ドメイン | 業務上の整合性、状態遷移 | 単純な入力チェックまで重くなる |
| API境界 | 外部入力を信用できる型へ変換 | 変換と表示ロジックが混ざる |
例えば、メールアドレスの形式はスキーマで見ます。一方で、「退会済みユーザーには招待メールを送れない」という判断はドメインルールです。
この分離がないと、フォーム修正のたびに業務ルールまで壊れます。逆に、すべてをドメイン層へ寄せると、ユーザーへの入力補助が遅くなります。
TypeScript バリデーション設計の失敗例
次に、レビューでよく見かける失敗例を整理します。どれも小さな実装では動きます。しかし、画面数やAPI数が増えると保守が重くなります。
失敗例1: フォームごとに同じチェックを書く
まず多いのは、同じ入力項目のチェックを画面ごとに書くパターンです。
if (!email.includes("@")) {
setError("メールアドレスの形式が正しくありません");
}
if (password.length < 12) {
setError("パスワードは12文字以上で入力してください");
}
この実装は手早いです。ただし、登録画面、招待画面、管理画面で条件がずれます。その結果、同じメールアドレスなのに画面ごとに通ったり落ちたりします。
そこで、共通スキーマを作ります。ただし、すべてを共通化する必要はありません。画面固有の補助メッセージや任意項目は、画面側に残す判断もあります。
失敗例2: APIレスポンスを信じて画面に渡す
API連携では、レスポンス型をそのまま画面へ渡す実装も危険です。バックエンドの変更、古いデータ、移行中のnullable値で画面が壊れます。
もちろん、社内APIだから安全という判断もあります。しかし、実務では仕様書、実装、データ移行、運用値が完全に揃うとは限りません。
そのため、API境界で検証し、画面用モデルへ変換します。これはTypeScript API型設計やunknown設計ともつながる観点です。
失敗例3: バリデーションエラーを例外だけで扱う
バリデーション失敗をすべて例外にすると、UIで扱いにくくなります。特にフォームでは、項目ごとのエラー表示が必要です。
一方で、API境界で壊れたレスポンスを検知した場合は、画面入力エラーとは意味が違います。ユーザーに直させる問題ではないため、ログや監視につなげる設計が必要です。
zodを使うTypeScript バリデーション設計
TypeScriptで実行時バリデーションを扱う場合、Zodのようなスキーマ検証ライブラリがよく使われます。
Zodの利点は、実行時検証とTypeScriptの型推論を近い場所に置けることです。例えば、入力フォームのスキーマは次のように書けます。
import { z } from "zod";
const userFormSchema = z.object({
email: z.string().email(),
displayName: z.string().min(1).max(40),
age: z.number().int().min(18),
});
type UserFormValues = z.infer<typeof userFormSchema>;
この形なら、検証ルールとフォーム値の型が離れません。さらに、APIへ送るDTOへ変換する前に、入力値の前提を揃えられます。
ただし、Zodを入れれば設計が自動で良くなるわけではありません。置き場所、エラーメッセージ、スキーマの粒度、変換責務を決める必要があります。
スキーマは画面単位かドメイン単位か
スキーマ設計で迷うのは、画面単位で作るか、ドメイン単位で作るかです。
画面単位のスキーマは、UI要件に合わせやすいです。例えば、確認画面だけ任意、編集画面だけ必須、という条件を表現しやすくなります。
一方で、画面単位だけに寄せると業務ルールが重複します。そのため、共通の基本スキーマを作り、画面ごとに拡張する設計が現実的です。
const baseUserSchema = z.object({
email: z.string().email(),
displayName: z.string().min(1).max(40),
});
const createUserSchema = baseUserSchema.extend({
password: z.string().min(12),
});
const updateUserSchema = baseUserSchema.partial();
このように分けると、共通条件と画面固有条件が見えます。レビューでも「なぜ作成だけ必須なのか」を確認しやすくなります。
API境界のバリデーションはunknownから始める
APIレスポンスの検証では、`unknown`から始める設計が有効です。理由は、外部から来た値を最初から信用しない前提をコードに残せるためです。
const userResponseSchema = z.object({
id: z.string(),
email: z.string().email(),
status: z.enum(["active", "inactive"]),
lastLoginAt: z.string().nullable(),
});
type UserResponse = z.infer<typeof userResponseSchema>;
async function fetchUser(): Promise<UserResponse> {
const response = await fetch("/api/user");
const data: unknown = await response.json();
return userResponseSchema.parse(data);
}
この実装では、レスポンスの形が壊れていれば境界で止まります。その結果、画面の深い場所で原因不明の表示崩れが起きにくくなります。
なお、TypeScript設定では`strict`系のオプションも重要です。プロジェクトの前提を確認するときは、TypeScript TSConfigのstrictも見ておくとよいです。
レビューで見るTypeScript バリデーション設計の観点
レビューでは、単に「チェックがあるか」だけを見ても不十分です。重要なのは、どの境界で何を守っているかです。
| レビュー観点 | 確認したいこと | 危険な兆候 |
|---|---|---|
| 責務 | UI、スキーマ、ドメイン、API境界が分かれているか | 画面コンポーネントに業務ルールが大量にある |
| 型 | 検証後の型が自然に推論されるか | `as`で無理に型を合わせている |
| エラー | ユーザー入力エラーとシステム異常が分かれているか | 全部同じtoastで表示している |
| 再利用 | 共通化する範囲が妥当か | 小さな画面差分まで共通スキーマに押し込む |
| 運用 | API破壊時に検知できるか | 画面側でnullを握りつぶしている |
特に`as User`の多用には注意が必要です。型エラーを消すためのキャストは、バリデーションの代わりにはなりません。
また、UI側で`value ?? “”`を広く使う実装も確認します。表示崩れを防ぐ意図なら有効です。しかし、APIの欠損を隠してしまう場合は、検知すべき不具合を見逃します。
サーバー側バリデーションとのトレードオフ
フロントエンドで検証しているから、サーバー側検証は不要という判断はできません。最終的な信頼境界はサーバー側にあります。
フロントエンドのバリデーションは、入力体験を良くする役割が大きいです。一方で、サーバー側は不正なリクエストを拒否し、データの整合性を守ります。
そのため、重要な業務ルールはサーバー側にも必要です。ただし、同じルールを別々に書くと差分が出ます。OpenAPI、共通スキーマ、契約テストなどで差分を検知する仕組みも検討します。
よくある質問
TypeScriptの型があればバリデーションは不要ですか?
不要ではありません。TypeScriptの型はコンパイル時の仕組みです。APIレスポンス、フォーム入力、URLパラメータなどは実行時に検証する必要があります。
すべてのAPIレスポンスをzodで検証すべきですか?
必ずしも全件ではありません。重要画面、課金、権限、外部API連携、障害時の影響が大きい箇所から優先します。社内APIでも、変更頻度が高い境界は検証対象にしやすいです。
フォームライブラリのバリデーションだけで十分ですか?
入力体験だけなら十分な場合があります。ただし、業務ルールやAPI境界の検証までフォームに閉じ込めると再利用しにくくなります。責務を分ける方が変更に強くなります。
まとめ
TypeScript バリデーション設計では、型と実行時検証を分けて考えることが重要です。
- TypeScriptの型はコンパイル時の約束であり、外部入力は実行時に検証する
- UI、スキーマ、ドメイン、API境界で責務を分ける
- zodや型ガードは、設計方針とレビュー観点があって初めて効果を発揮する
TypeScriptやReactの案件では、フォームやAPI連携の設計判断が保守性に直結します。現場でどこまで型安全にするか、どの案件で設計経験を伸ばすかを相談したい方は、案件選びの段階で一度整理してみてください。
一度カジュアル面談をしませんか?
株式会社bluenaは「高還元」と「伴走支援」を両立したSES企業です。単価の81〜86%を還元する報酬体系と、専任サポーターによる隔週1on1で、エンジニアが納得できるキャリアを実現します。
まとまっていなくてもOK——まずは現在地を聞かせてください。
カジュアル面談ですので、お気軽にお聞かせください。





