ランタイムバリデーションがTypeScriptプロジェクトに欠けている理由
TypeScriptはコンパイル時の型安全性を提供してくれますが、データがネットワーク境界を越えた瞬間、すべての保証は消えてしまいます。APIレスポンス、フォーム送信、クエリパラメータ——これらはどれもランタイムでは型付けされていません。TypeScriptは宣言した型の形をただ信頼するだけです。実際のデータがその宣言と一致しない場合、本番環境でしか表面化しないサイレントバグが発生します。
私自身、これで何度も痛い目に遭ってきました。実際の開発経験から言えば、アプリケーションのエッジ——信頼できない外部世界と型付きTypeScriptコードが出会う場所——でデータをバリデーションするスキルは、ぜひマスターすべき必須スキルのひとつです。そのギャップを埋めてくれるのが、まさにZodです。
ZodはTypeScript向けに設計されたスキーマ宣言・バリデーションライブラリです。スキーマを一度定義するだけで、ランタイムバリデーション(不正なデータを即座に拒否)とTypeScript型推論(型の二重宣言が不要)の2つを同時に得られます。両者は自動的に同期が取れるため、バグのカテゴリ全体をまるごと排除できます。
インストール
Zodは、Node.jsバックエンド、Reactフロントエンド、共有モノレポパッケージなど、あらゆるTypeScriptプロジェクトで動作します。
npm install zod
# または
pnpm add zod
# または
yarn add zod
React Hook Form連携には、Zodリゾルバーも必要です:
npm install react-hook-form @hookform/resolvers zod
依存関係はこれだけです。Zodは外部依存ゼロで、DenoやBunを含むすべてのモダンな環境で動作します。
TypeScriptのバージョンが4.5以上であることを確認してください——ZodはテンプレートリテラルTypeと新しい推論機能に依存しています:
npx tsc --version
設定:はじめてのスキーマを構築する
基本的なスキーマ定義と型推論
コアコンセプトはシンプルです:Zodのプリミティブを使ってスキーマを定義し、z.inferを使ってTypeScript型を取り出します。
import { z } from 'zod';
// スキーマを一度定義する
const UserSchema = z.object({
id: z.number().int().positive(),
email: z.string().email(),
username: z.string().min(3).max(20),
role: z.enum(['admin', 'editor', 'viewer']),
createdAt: z.string().datetime().optional(),
});
// TypeScript型を取り出す — 重複なし
type User = z.infer<typeof UserSchema>;
// Userは以下と等価:
// {
// id: number;
// email: string;
// username: string;
// role: 'admin' | 'editor' | 'viewer';
// createdAt?: string | undefined;
// }
これが重要なポイントです:スキーマを書けば、TypeScriptが型を自動で書いてくれます。スキーマを更新すると、型も自動的に更新されます。バリデーションロジックにフィールドを追加する際に、インターフェースの更新を忘れることはもうありません。
ランタイムパースとエラーハンドリング
Zodはデータバリデーションに2つのメソッドを提供します:parse()は失敗時に例外をスロー、safeParse()は結果オブジェクトを返します。
const rawData = {
id: 1,
email: '[email protected]',
username: 'john_doe',
role: 'admin',
};
// オプション1: parse — 無効な場合はZodErrorをスロー
try {
const user = UserSchema.parse(rawData);
console.log(user.email); // 完全に型付き
} catch (err) {
console.error(err); // ZodError(詳細なメッセージ付き)
}
// オプション2: safeParse — { success, data } または { success, error } を返す
const result = UserSchema.safeParse(rawData);
if (result.success) {
console.log(result.data.role); // 'admin' | 'editor' | 'viewer' として型付き
} else {
console.error(result.error.flatten());
// { fieldErrors: { email: ['メールアドレスが無効です'] }, formErrors: [] }
}
safeParse()はtry/catchのノイズなしに明示的にエラーを処理できるため、アプリケーションコードでは一般的にこちらが好まれます。実行フローを中断することがないのがポイントです。
実践的なパターン:変換、デフォルト値、ネストされたスキーマ
スキーマは自然に組み合わせられます。また、Zodはパース時にデータを変換・成形するトランスフォームをサポートしています:
// ネストされたスキーマ
const AddressSchema = z.object({
street: z.string(),
city: z.string(),
country: z.string().length(2), // ISO国コード
});
const ProfileSchema = z.object({
user: UserSchema,
address: AddressSchema.optional(),
tags: z.array(z.string()).default([]),
});
// トランスフォーム:日付文字列をDateオブジェクトに変換
const EventSchema = z.object({
name: z.string(),
startDate: z.string().transform((val) => new Date(val)),
attendeeCount: z.coerce.number(), // 文字列 '42' を数値 42 に強制変換
});
type Event = z.infer<typeof EventSchema>;
// startDate はstring ではなく Date(変換後)
// リファインメント:カスタムバリデーションロジック
const PasswordSchema = z
.object({
password: z.string().min(8),
confirm: z.string(),
})
.refine((data) => data.password === data.confirm, {
message: 'パスワードが一致しません',
path: ['confirm'],
});
ZodとReact Hook Formを連携する
React Hook Formはフォームの状態を効率的に管理しますが、その組み込みバリデーションはTypeScript型とは別物です。@hookform/resolversパッケージがこのギャップを埋めてくれます——ZodスキーマをリゾルバーとしてReact Hook Formに渡すだけで、バリデーションとTypeScript型推論の両方に使われます。
// components/RegistrationForm.tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const RegistrationSchema = z
.object({
email: z.string().email('有効なメールアドレスを入力してください'),
username: z
.string()
.min(3, 'ユーザー名は3文字以上必要です')
.max(20, 'ユーザー名は20文字以内にしてください')
.regex(/^[a-z0-9_]+$/, '小文字、数字、アンダースコアのみ使用できます'),
password: z.string().min(8, 'パスワードは8文字以上必要です'),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'パスワードが一致しません',
path: ['confirmPassword'],
});
type RegistrationFormData = z.infer<typeof RegistrationSchema>;
export function RegistrationForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<RegistrationFormData>({
resolver: zodResolver(RegistrationSchema),
});
const onSubmit = async (data: RegistrationFormData) => {
// data は完全に型付きで、すでにバリデーション済み
await fetch('/api/register', {
method: 'POST',
body: JSON.stringify(data),
});
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<input {...register('email')} placeholder="メールアドレス" />
{errors.email && <p>{errors.email.message}</p>}
</div>
<div>
<input {...register('username')} placeholder="ユーザー名" />
{errors.username && <p>{errors.username.message}</p>}
</div>
<div>
<input type="password" {...register('password')} placeholder="パスワード" />
{errors.password && <p>{errors.password.message}</p>}
</div>
<div>
<input
type="password"
{...register('confirmPassword')}
placeholder="パスワード確認"
/>
{errors.confirmPassword && <p>{errors.confirmPassword.message}</p>}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'アカウント作成中...' : '登録'}
</button>
</form>
);
}
この設定の素晴らしい点は:同じRegistrationSchemaをAPIルートにインポートして、受信リクエストボディのバリデーションにも使えることです。一つのスキーマで、クライアントとサーバー両方でバリデーションできます。
フロントエンドとバックエンド間でスキーマを共有する
ここでフルスタックの利点が明確になります。共有スキーマファイルを作成してみましょう:
// shared/schemas.ts
import { z } from 'zod';
export const RegistrationSchema = z
.object({
email: z.string().email(),
username: z.string().min(3).max(20),
password: z.string().min(8),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'パスワードが一致しません',
path: ['confirmPassword'],
});
export type RegistrationFormData = z.infer<typeof RegistrationSchema>;
// pages/api/register.ts (Next.js API route)
import { RegistrationSchema } from '@/shared/schemas';
export async function POST(request: Request) {
const body = await request.json();
const result = RegistrationSchema.safeParse(body);
if (!result.success) {
return Response.json(
{ errors: result.error.flatten().fieldErrors },
{ status: 400 }
);
}
// result.data はここで完全に型付き
const { email, username, password } = result.data;
// ... ユーザーを作成
}
検証とモニタリング:スキーマが正しく機能するかテストする
スキーマを直接ユニットテストする
スキーマは純粋な関数です——モックなしで簡単にユニットテストできます:
// schemas.test.ts
import { RegistrationSchema } from './shared/schemas';
describe('RegistrationSchema', () => {
it('有効な登録データを受け付ける', () => {
const result = RegistrationSchema.safeParse({
email: '[email protected]',
username: 'john_doe',
password: 'secure123',
confirmPassword: 'secure123',
});
expect(result.success).toBe(true);
});
it('パスワードが一致しない場合は拒否する', () => {
const result = RegistrationSchema.safeParse({
email: '[email protected]',
username: 'john_doe',
password: 'secure123',
confirmPassword: 'different456',
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.flatten().fieldErrors.confirmPassword).toBeDefined();
}
});
it('無効なメールフォーマットを拒否する', () => {
const result = RegistrationSchema.safeParse({
email: 'not-an-email',
username: 'john_doe',
password: 'secure123',
confirmPassword: 'secure123',
});
expect(result.success).toBe(false);
});
});
本番環境でのバリデーションエラーを監視する
本番環境でバリデーションが失敗した場合、その可視性が必要です。APIバリデーションをログ出力でラップしましょう:
const result = RegistrationSchema.safeParse(body);
if (!result.success) {
const errors = result.error.flatten();
// モニタリングサービスにログを送る(Sentry、Datadogなど)
console.warn('[バリデーション失敗]', {
endpoint: '/api/register',
fieldErrors: errors.fieldErrors,
formErrors: errors.formErrors,
});
return Response.json({ errors: errors.fieldErrors }, { status: 400 });
}
特定フィールドでバリデーション失敗が頻発する場合、フロントエンドのバグ、間違ったデータフォーマットを送信するモバイルクライアント、またはドキュメントを読んでいないAPIコンシューマーの存在を示していることが多いです。ユーザーから苦情が来てからログを追いかけるのではなく、早い段階でこれらを表面化させましょう。
よくある落とし穴
- OptionalとNullable:
z.string().optional()はundefinedを許可し、z.string().nullable()はnullを許可します。両者は異なります。両方許可するにはz.string().nullish()を使用してください。 - 未知のキー:デフォルトでは、Zodはパース時に未知のキーを削除します。保持するには
.passthrough()を、例外をスローするには.strict()を呼び出してください。 - 非同期リファインメント:バリデーションがデータベースにアクセスする必要がある場合(例:メールアドレスが既に使われているか確認する場合)は、
.refineAsync()とparseAsync()を使用してください。 - エラーメッセージ:カスタムメッセージはバリデーター呼び出しの中に入れます:
z.string().min(3, '短すぎます')— 別の設定オブジェクトではありません。

