TypeScript Zodバリデーション:ランタイム検証から型安全性までの5つのエンタープライズパターン
前端工程
TypeScriptの型はランタイムでは嘘だ
完璧なinterfaceを書き、TypeScriptコンパイルはエラーゼロ、しかし本番で爆発する——APIがstringではなくnullを返し、フォームが空文字でフロントエンド検証をバイパスし、サードパーティSDKの型宣言と実際の動作が完全に不一致。**TypeScriptの型はコンパイル時にしか存在せず、ランタイムでは何の保証もない。**2026年、ZodはTypeScriptエコシステムにおけるランタイム検証のデファクトスタンダードとなった——「スキーマを一度定義すれば、ランタイム検証とコンパイル時型推論の両方を取得」できる。
本記事はZodコア概念から出発し、スキーマ定義→フォームバリデーション→API検証→スキーマ変換→エラー処理の5つのエンタープライズパターンをガイドする。
Zodコア概念
| 概念 | 説明 |
|---|---|
| Schema | データ構造の宣言的記述、検証と型推論の両方を提供 |
| z.object() | オブジェクトスキーマ、TypeScriptのinterface/typeに対応 |
| z.infer<> | スキーマからTypeScript型を自動推論、重複定義を排除 |
| .transform() | スキーマ変換パイプライン、検証後にデータを変換 |
| .refine() | カスタム検証ロジック、非同期対応 |
| .pipe() | スキーマパイプライン合成、複数スキーマを連鎖 |
| ZodError | 検証エラーオブジェクト、詳細なエラーパスと情報を含む |
| .safeParse() | 安全なパース、例外を投げず、Success/Failure結果を返す |
| .partial() | 全フィールドをオプショナルに、更新操作に適している |
| .merge() | 2つのスキーマをマージ、TypeScriptの交差型に類似 |
検証フロー
データ入力 → schema.parse(data) → 検証通過 → 型安全なデータを返す
↓ 検証失敗
ZodErrorをスロー / safeParseが失敗結果を返す
型推論フロー:
1. スキーマ定義: const UserSchema = z.object({...})
2. 型推論: type User = z.infer<typeof UserSchema>
3. ランタイム検証: UserSchema.parse(apiResponse)
4. コンパイル時型: parse戻り値が自動的にUser型を取得
問題分析:ランタイム検証の5つの課題
- 型と検証の分断:TypeScriptのinterfaceとランタイム検証ロジックを2つ保守する必要があり、不一致になりやすい
- 複雑なフォームバリデーション:ネストされたオブジェクト、条件付き検証、クロスフィールド連動、手書き検証ロジックは保守困難
- API境界防護:フロントエンドがバックエンドを信頼、バックエンドがフロントエンドを信頼、どちらかの型仮定も破られる可能性
- データ変換ニーズ:文字列→数値、日付パース、デフォルト値充填、検証と変換が混在
- エラーメッセージローカライズ:Zodのデフォルト英語エラーメッセージ、エンタープライズアプリは中国語/多言語対応が必要
ステップバイステップ:5つのエンタープライズパターン
パターン1:基本スキーマ定義と型推論
import { z } from 'zod';
const UserSchema = z.object({
id: z.string().uuid(),
name: z.string().min(2, '名前は2文字以上必要').max(50, '名前は50文字以内'),
email: z.string().email('メール形式が正しくありません'),
age: z.number().int().min(0).max(150),
role: z.enum(['admin', 'editor', 'viewer']),
avatar: z.string().url().optional(),
createdAt: z.date(),
tags: z.array(z.string()).default([]),
metadata: z.record(z.string(), z.unknown()).default({}),
});
type User = z.infer<typeof UserSchema>;
const CreateUserSchema = UserSchema.omit({ id: true, createdAt: true });
type CreateUserInput = z.infer<typeof CreateUserSchema>;
const UpdateUserSchema = UserSchema.partial().omit({ id: true, createdAt: true });
type UpdateUserInput = z.infer<typeof UpdateUserSchema>;
function createUser(input: unknown): User {
return UserSchema.parse(input);
}
function safeCreateUser(input: unknown) {
return UserSchema.safeParse(input);
}
const result = safeCreateUser({
id: '550e8400-e29b-41d4-a716-446655440000',
name: '田中太郎',
email: 'tanaka@example.com',
age: 28,
role: 'admin',
createdAt: new Date(),
});
if (result.success) {
console.log(result.data.name);
} else {
console.log(result.error.issues);
}
パターン2:フォームバリデーション——ネストオブジェクトとクロスフィールド検証
import { z } from 'zod';
const AddressSchema = z.object({
province: z.string().min(1, '都道府県を選択してください'),
city: z.string().min(1, '市区町村を選択してください'),
district: z.string().min(1, '区を選択してください'),
detail: z.string().min(5, '詳細住所は5文字以上必要').max(200),
postalCode: z.string().regex(/^\d{7}$/, '郵便番号形式が正しくありません'),
});
const RegistrationFormSchema = z.object({
username: z.string()
.min(3, 'ユーザー名は3文字以上必要')
.max(20, 'ユーザー名は20文字以内')
.regex(/^[a-zA-Z0-9_]+$/, 'ユーザー名は英数字とアンダースコアのみ'),
password: z.string()
.min(8, 'パスワードは8文字以上必要')
.regex(/[A-Z]/, '大文字を含む必要があります')
.regex(/[a-z]/, '小文字を含む必要があります')
.regex(/[0-9]/, '数字を含む必要があります')
.regex(/[^A-Za-z0-9]/, '特殊文字を含む必要があります'),
confirmPassword: z.string(),
email: z.string().email('メール形式が正しくありません'),
phone: z.string().regex(/^0\d{9,10}$/, '電話番号形式が正しくありません'),
address: AddressSchema,
agreeTerms: z.literal(true, { errorMap: () => ({ message: '利用規約に同意してください' }) }),
}).refine(data => data.password === data.confirmPassword, {
message: 'パスワードが一致しません',
path: ['confirmPassword'],
}).refine(data => !data.password.includes(data.username), {
message: 'パスワードにユーザー名を含めることはできません',
path: ['password'],
});
type RegistrationForm = z.infer<typeof RegistrationFormSchema>;
function validateRegistrationForm(formData: unknown) {
return RegistrationFormSchema.safeParse(formData);
}
パターン3:APIリクエスト/レスポンス検証
import { z } from 'zod';
import type { Request, Response, NextFunction } from 'express';
const PaginationSchema = z.object({
page: z.coerce.number().int().positive().default(1),
pageSize: z.coerce.number().int().positive().max(100).default(20),
sortBy: z.string().optional(),
sortOrder: z.enum(['asc', 'desc']).default('desc'),
});
const ArticleQuerySchema = PaginationSchema.extend({
category: z.string().optional(),
tag: z.string().optional(),
keyword: z.string().optional(),
status: z.enum(['draft', 'published', 'archived']).optional(),
startDate: z.coerce.date().optional(),
endDate: z.coerce.date().optional(),
}).refine(data => {
if (data.startDate && data.endDate) {
return data.startDate <= data.endDate;
}
return true;
}, {
message: '開始日は終了日より後にはできません',
path: ['endDate'],
});
const ArticleResponseSchema = z.object({
id: z.string(),
title: z.string(),
content: z.string(),
category: z.string(),
tags: z.array(z.string()),
status: z.enum(['draft', 'published', 'archived']),
author: z.object({
id: z.string(),
name: z.string(),
avatar: z.string().optional(),
}),
publishedAt: z.string().datetime().nullable(),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
});
const ArticleListResponseSchema = z.object({
items: z.array(ArticleResponseSchema),
total: z.number(),
page: z.number(),
pageSize: z.number(),
});
function validateRequest<T>(schema: z.ZodSchema<T>) {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.query);
if (!result.success) {
res.status(400).json({
error: 'VALIDATION_ERROR',
details: result.error.issues.map(issue => ({
path: issue.path.join('.'),
message: issue.message,
})),
});
return;
}
req.query = result.data as any;
next();
};
}
function validateResponse<T>(schema: z.ZodSchema<T>, data: unknown): T {
const result = schema.safeParse(data);
if (!result.success) {
console.error('Response validation failed:', result.error.issues);
throw new Error('Internal server error: response validation failed');
}
return result.data;
}
パターン4:スキーマ変換とパイプライン
import { z } from 'zod';
const StringToNumber = z.string().transform((val, ctx) => {
const parsed = Number(val);
if (isNaN(parsed)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `"${val}" を数値に変換できません`,
});
return z.NEVER;
}
return parsed;
});
const DateStringSchema = z.string().transform((val, ctx) => {
const date = new Date(val);
if (isNaN(date.getTime())) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `無効な日付文字列: ${val}`,
});
return z.NEVER;
}
return date;
});
const CSVRowSchema = z.object({
name: z.string().trim().min(1),
age: StringToNumber.pipe(z.number().int().min(0).max(150)),
email: z.string().email().toLowerCase(),
registeredAt: DateStringSchema.pipe(z.date()),
isActive: z.string().transform(val => val === 'true' || val === '1').pipe(z.boolean()),
score: StringToNumber.pipe(z.number().min(0).max(100)),
tags: z.string().transform(val => val.split(',').map(s => s.trim()).filter(Boolean)).pipe(z.array(z.string())),
});
type CSVRow = z.infer<typeof CSVRowSchema>;
function parseCSVRow(row: Record<string, string>): CSVRow {
return CSVRowSchema.parse(row);
}
パターン5:エラー処理とローカライズ
import { z } from 'zod';
import { zodI18nMap } from 'zod-i18n-map';
import i18next from 'i18next';
i18next.init({
lng: 'ja',
resources: {
ja: {
zod: {
errors: {
invalid_type: ({ expected }) => `型エラー、${expected} が必要です`,
too_small: ({ minimum, type }) => {
if (type === 'string') return `${minimum} 文字以上必要です`;
if (type === 'number') return `${minimum} 以上の値が必要です`;
return `値が小さすぎます`;
},
too_big: ({ maximum, type }) => {
if (type === 'string') return `${maximum} 文字以内です`;
if (type === 'number') return `${maximum} 以下の値が必要です`;
return `値が大きすぎます`;
},
invalid_string: ({ validation }) => {
const messages: Record<string, string> = {
email: 'メール形式が正しくありません',
url: 'URL形式が正しくありません',
uuid: 'UUID形式が正しくありません',
regex: '形式が正しくありません',
};
return messages[validation] || '文字列形式が正しくありません';
},
invalid_enum: ({ options }) => `次のいずれかである必要があります: ${options.join(', ')}`,
required: 'この項目は必須です',
},
},
},
},
});
z.setErrorMap(zodI18nMap);
interface FieldError {
field: string;
message: string;
code: string;
}
function formatZodErrors(error: z.ZodError): FieldError[] {
return error.issues.map(issue => ({
field: issue.path.join('.'),
message: issue.message,
code: issue.code,
}));
}
function groupErrorsByField(error: z.ZodError): Record<string, string[]> {
return error.issues.reduce<Record<string, string[]>>((acc, issue) => {
const field = issue.path.join('.');
if (!acc[field]) acc[field] = [];
acc[field].push(issue.message);
return acc;
}, {});
}
const ProductSchema = z.object({
name: z.string().min(2).max(100),
price: z.number().positive(),
category: z.enum(['electronics', 'clothing', 'food']),
description: z.string().min(10),
});
const productResult = ProductSchema.safeParse({
name: 'A',
price: -10,
category: 'invalid',
description: 'short',
});
if (!productResult.success) {
console.log(formatZodErrors(productResult.error));
console.log(groupErrorsByField(productResult.error));
}
落とし穴ガイド
落とし穴1:z.coerceが無効な値をサイレントに変換
// ❌ 誤り:z.coerceは無効な値をNaNや0にサイレント変換
const schema = z.coerce.number();
schema.parse('abc'); // NaN、エラーなし!
schema.parse(null); // 0、エラーなし!
// ✅ 正しい:元の型を先に検証してから変換
const safeNumberSchema = z.string().transform((val, ctx) => {
const num = Number(val);
if (isNaN(num)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: '数値に変換できません',
});
return z.NEVER;
}
return num;
}).pipe(z.number());
落とし穴2:.optional()と.undefined()の混同
// ❌ 誤り:.optional()はundefinedを許可するが動作が紛らわしい
const schema = z.object({
name: z.string().optional(),
});
schema.parse({ name: undefined }); // OK
schema.parse({}); // これもOK、しかし紛らわしい
// ✅ 正しい:オプショナルとnullableを明確に区別
const betterSchema = z.object({
name: z.string().optional(),
nickname: z.string().nullable().optional(),
});
落とし穴3:refineでbooleanの返し忘れ
// ❌ 誤り:refineコールバックに戻り値がない
.refine(data => {
data.password === data.confirmPassword;
})
// ✅ 正しい:refineはbooleanを返す必要がある
.refine(data => data.password === data.confirmPassword, {
message: 'パスワードが一致しません',
path: ['confirmPassword'],
})
落とし穴4:Dateオブジェクト使用によるシリアライズ問題
// ❌ 誤り:z.date()はDateオブジェクト入力が必要、JSONパース後は文字列
const schema = z.object({
createdAt: z.date(),
});
schema.parse(JSON.parse('{"createdAt":"2024-01-01"}')); // エラー!
// ✅ 正しい:z.string().datetime()で文字列を受け取り変換
const schema = z.object({
createdAt: z.string().datetime().transform(s => new Date(s)),
});
// またはz.coerce.date()で自動変換
const coerceSchema = z.object({
createdAt: z.coerce.date(),
});
落とし穴5:スキーマ定義の循環参照
// ❌ 誤り:直接循環参照は無限再帰を引き起こす
const TreeNodeSchema = z.object({
value: z.string(),
children: z.array(TreeNodeSchema), // ReferenceError!
});
// ✅ 正しい:z.lazyで遅延評価
const TreeNodeSchema: z.ZodType<TreeNode> = z.lazy(() =>
z.object({
value: z.string(),
children: z.array(TreeNodeSchema).default([]),
})
);
interface TreeNode {
value: string;
children: TreeNode[];
}
エラートラブルシューティング
| # | エラーメッセージ | 原因 | 解決方法 |
|---|---|---|---|
| 1 | Expected string, received number |
型不一致 | z.coerceや.transformで型変換 |
| 2 | Required at path "field" |
必須フィールド欠落 | .optional()を追加またはデフォルト値を提供 |
| 3 | String must contain at least N character(s) |
文字列が短すぎる | .min()制約または入力データを確認 |
| 4 | Invalid enum value |
列挙値不一致 | z.enum()定義を確認、入力値が列挙リストにあるか |
| 5 | Invalid datetime |
日付形式不正 | ISO 8601形式またはz.coerce.date()を使用 |
| 6 | Custom message: ... |
refine検証失敗 | refine/transformのカスタムロジックを確認 |
| 7 | Too many items in array |
配列長超過 | .max()制約を調整または入力データを確認 |
| 8 | Invalid URL |
URL形式不正 | プロトコルプレフィックス(http/https)を確認 |
| 9 | Invalid email |
メール形式不正 | @記号とドメイン部分を確認 |
| 10 | Pipeline transform error |
パイプ変換失敗 | 各パイプライン段階の入出力型マッチングを確認 |
高度な最適化
1. スキーマ再利用と合成パターン
import { z } from 'zod';
const BaseEntitySchema = z.object({
id: z.string().uuid(),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
});
const NamedEntitySchema = BaseEntitySchema.extend({
name: z.string().min(1),
description: z.string().optional(),
});
const ProductSchema = NamedEntitySchema.extend({
price: z.number().positive(),
category: z.string(),
sku: z.string().regex(/^[A-Z]{2}\d{6}$/),
variants: z.array(z.object({
name: z.string(),
price: z.number().positive(),
stock: z.number().int().nonnegative(),
})).default([]),
});
const OrderSchema = NamedEntitySchema.extend({
userId: z.string().uuid(),
items: z.array(z.object({
productId: z.string().uuid(),
quantity: z.number().int().positive(),
unitPrice: z.number().positive(),
})).min(1, '注文には少なくとも1つの商品が必要です'),
totalAmount: z.number().nonnegative(),
status: z.enum(['pending', 'paid', 'shipped', 'delivered', 'cancelled']),
});
type Product = z.infer<typeof ProductSchema>;
type Order = z.infer<typeof OrderSchema>;
2. 条件付きスキーマ——フィールド値に基づく動的検証
import { z } from 'zod';
const PaymentSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('credit_card'),
cardNumber: z.string().regex(/^\d{16}$/, 'カード番号は16桁の数字が必要'),
expiryMonth: z.number().int().min(1).max(12),
expiryYear: z.number().int().min(2026).max(2036),
cvv: z.string().regex(/^\d{3,4}$/, 'CVV形式が正しくありません'),
}),
z.object({
type: z.literal('bank_transfer'),
bankCode: z.string().min(1, '銀行を選択してください'),
accountNumber: z.string().regex(/^\d{10,20}$/, '口座番号形式が正しくありません'),
accountName: z.string().min(1, '口座名義を入力してください'),
}),
z.object({
type: z.literal('alipay'),
alipayAccount: z.string().email('Alipayにバインドされたメールを入力してください'),
}),
]);
type Payment = z.infer<typeof PaymentSchema>;
function validatePayment(data: unknown) {
return PaymentSchema.safeParse(data);
}
3. スキーマバージョン移行
import { z } from 'zod';
const UserV1Schema = z.object({
name: z.string(),
email: z.string().email(),
});
const UserV2Schema = z.object({
firstName: z.string(),
lastName: z.string(),
email: z.string().email(),
phone: z.string().optional(),
});
const UserV3Schema = z.object({
firstName: z.string(),
lastName: z.string(),
email: z.string().email(),
phone: z.string().optional(),
avatar: z.string().url().optional(),
preferences: z.object({
language: z.enum(['zh-CN', 'en', 'ja']).default('ja'),
theme: z.enum(['light', 'dark', 'system']).default('system'),
}).default({}),
});
function migrateUserV1ToV3(v1Data: unknown) {
const v1 = UserV1Schema.parse(v1Data);
const nameParts = v1.name.split(' ');
return UserV3Schema.parse({
firstName: nameParts[0] || '',
lastName: nameParts.slice(1).join(' ') || '',
email: v1.email,
});
}
function migrateUserV2ToV3(v2Data: unknown) {
const v2 = UserV2Schema.parse(v2Data);
return UserV3Schema.parse({
...v2,
});
}
比較分析
| 次元 | Zod | Yup | Joi | class-validator | io-ts |
|---|---|---|---|---|---|
| TS型推論 | ✅ 自動 | ⚠️ 手動 | ❌ なし | ⚠️ デコレータ | ✅ 自動 |
| バンドルサイズ | ~13KB | ~18KB | ~180KB | ~35KB | ~8KB |
| 非同期検証 | ✅ refine | ✅ validate | ✅ validateAsync | ❌ | ✅ |
| スキーマ変換 | ✅ transform | ❌ | ❌ | ❌ | ✅ |
| エラーローカライズ | ✅ zod-i18n-map | ⚠️ 手動 | ⚠️ 手動 | ⚠️ 手動 | ❌ |
| イミュータブルデータ | ✅ | ✅ | ✅ | ❌ | ✅ |
| 学習曲線 | 低 | 低 | 中 | 中 | 高 |
| エコシステム | React/Next/Express | Formik | Express | NestJS | fp-ts |
まとめ:Zodは「また一つの検証ライブラリ」ではなく、「TypeScriptランタイム型システムの補完者」です。コアバリューは
z.infer<>にあります——スキーマを一度定義すれば、ランタイム検証とコンパイル時型の両方を取得し、interfaceと検証ロジックの分断を完全に排除します。2026年のエンタープライズ実践パス:まずZodで手書き検証を置き換え→次にReact Hook Formと統合してフォームバリデーション→最後にZodミドルウェアでAPI境界をガード。鍵は「外部データを一切信頼しない」意識を構築すること——API、フォーム、ファイル読み取りからの全データは、スキーマ検証を通過してからビジネスロジックに入る必要があります。
オンラインツール推奨
- JSONフォーマッター:/ja/json/format
- Base64エンコード/デコード:/ja/encode/base64
- Hash計算:/ja/encode/hash
- JWTデコード:/ja/encode/jwt-decode
ブラウザローカルツールを無料で試す →
#TypeScript#Zod#Schema验证#运行时校验#表单验证#2026#类型安全