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つの課題

  1. 型と検証の分断:TypeScriptのinterfaceとランタイム検証ロジックを2つ保守する必要があり、不一致になりやすい
  2. 複雑なフォームバリデーション:ネストされたオブジェクト、条件付き検証、クロスフィールド連動、手書き検証ロジックは保守困難
  3. API境界防護:フロントエンドがバックエンドを信頼、バックエンドがフロントエンドを信頼、どちらかの型仮定も破られる可能性
  4. データ変換ニーズ:文字列→数値、日付パース、デフォルト値充填、検証と変換が混在
  5. エラーメッセージローカライズ: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、フォーム、ファイル読み取りからの全データは、スキーマ検証を通過してからビジネスロジックに入る必要があります。


オンラインツール推奨

ブラウザローカルツールを無料で試す →

#TypeScript#Zod#Schema验证#运行时校验#表单验证#2026#类型安全