TypeScript バリデーション設計で実務が崩れない境界を作る

TypeScript バリデーション設計でUIとAPI境界を整理する図

バリデーション設計は型だけに任せない

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連携の設計判断が保守性に直結します。現場でどこまで型安全にするか、どの案件で設計経験を伸ばすかを相談したい方は、案件選びの段階で一度整理してみてください。

IaC INP PM PMO PMP UX Webディレクター インフラエンジニア キャリアチェンジ フロントエンドエンジニア