Zod v4リリース - TypeScript型安全バリデーションの進化

2025.07.10

Zod v4の概要

Zod v4は、TypeScript向けスキーマバリデーションライブラリのメジャーバージョンです。パフォーマンス改善、より良いエラーメッセージ、新しいユーティリティが追加されています。

パフォーマンス改善

項目Zod v3Zod v4改善率
オブジェクトパース速度10.2μs6.8μs33% faster
配列パース速度(1000要素)850μs520μs39% faster
バンドルサイズ(gzip)14KB10KB29% smaller

新機能

  • 改善されたエラーメッセージ
  • JSON Schema出力の強化
  • メタデータAPI
  • 新しいプリミティブ型
  • カスタムバリデーションの簡素化

基本的な使い方

import { z } from 'zod';

// スキーマ定義
const UserSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  name: z.string().min(2).max(100),
  age: z.number().int().positive().optional(),
  role: z.enum(['admin', 'user', 'guest']),
  preferences: z.object({
    theme: z.enum(['light', 'dark']).default('light'),
    notifications: z.boolean().default(true),
  }).optional(),
  createdAt: z.coerce.date(),
});

// 型推論
type User = z.infer<typeof UserSchema>;
// {
//   id: string;
//   email: string;
//   name: string;
//   age?: number;
//   role: 'admin' | 'user' | 'guest';
//   preferences?: { theme: 'light' | 'dark'; notifications: boolean };
//   createdAt: Date;
// }

// バリデーション
const result = UserSchema.safeParse(input);
if (result.success) {
  console.log(result.data); // 型安全
} else {
  console.log(result.error.issues);
}

改善されたエラーメッセージ

// Zod v4: より詳細なエラーパス
const OrderSchema = z.object({
  items: z.array(
    z.object({
      productId: z.string().uuid(),
      quantity: z.number().int().positive(),
    })
  ).min(1),
});

const result = OrderSchema.safeParse({
  items: [
    { productId: 'invalid-uuid', quantity: -1 },
  ],
});

// エラー出力
result.error?.issues.forEach((issue) => {
  console.log({
    path: issue.path.join('.'),  // "items.0.productId"
    message: issue.message,       // "Invalid uuid"
    code: issue.code,             // "invalid_string"
  });
});

カスタムエラーメッセージ

const FormSchema = z.object({
  email: z.string({
    required_error: 'メールアドレスは必須です',
    invalid_type_error: 'メールアドレスは文字列で入力してください',
  }).email({
    message: '有効なメールアドレスを入力してください',
  }),

  password: z.string()
    .min(8, { message: 'パスワードは8文字以上必要です' })
    .regex(/[A-Z]/, { message: '大文字を1文字以上含めてください' })
    .regex(/[0-9]/, { message: '数字を1文字以上含めてください' }),

  age: z.number({
    required_error: '年齢を入力してください',
    invalid_type_error: '年齢は数値で入力してください',
  }).int().min(0).max(150),
});

// 国際化対応エラーマップ
const errorMap: z.ZodErrorMap = (issue, ctx) => {
  if (issue.code === z.ZodIssueCode.too_small) {
    if (issue.type === 'string') {
      return { message: `${issue.minimum}文字以上入力してください` };
    }
    if (issue.type === 'number') {
      return { message: `${issue.minimum}以上の値を入力してください` };
    }
  }
  return { message: ctx.defaultError };
};

z.setErrorMap(errorMap);

新しいユーティリティ型

z.coerce(型強制)

// 文字列を数値に変換
const NumberSchema = z.coerce.number();
NumberSchema.parse('42');     // 42
NumberSchema.parse('3.14');   // 3.14
NumberSchema.parse(true);     // 1

// 文字列を日付に変換
const DateSchema = z.coerce.date();
DateSchema.parse('2024-01-01');           // Date
DateSchema.parse(1704067200000);          // Date
DateSchema.parse(new Date());             // Date

// 文字列をブール値に変換
const BoolSchema = z.coerce.boolean();
BoolSchema.parse('true');     // true
BoolSchema.parse('false');    // false
BoolSchema.parse(1);          // true
BoolSchema.parse(0);          // false

z.pipe(変換チェーン)

// 文字列 → 数値 → バリデーション
const PriceSchema = z
  .string()
  .pipe(z.coerce.number().positive());

PriceSchema.parse('100');   // 100
PriceSchema.parse('-50');   // Error: Number must be positive

// JSON文字列のパース
const JsonDataSchema = z
  .string()
  .transform((str) => JSON.parse(str))
  .pipe(z.object({
    name: z.string(),
    value: z.number(),
  }));

JsonDataSchema.parse('{"name": "test", "value": 42}');

z.discriminatedUnion(判別共用体)

// イベントタイプの判別
const EventSchema = z.discriminatedUnion('type', [
  z.object({
    type: z.literal('user_created'),
    userId: z.string(),
    email: z.string().email(),
  }),
  z.object({
    type: z.literal('user_updated'),
    userId: z.string(),
    changes: z.record(z.unknown()),
  }),
  z.object({
    type: z.literal('user_deleted'),
    userId: z.string(),
    deletedAt: z.date(),
  }),
]);

type Event = z.infer<typeof EventSchema>;
// TypeScriptで判別可能
function handleEvent(event: Event) {
  switch (event.type) {
    case 'user_created':
      console.log(event.email); // 型安全
      break;
    case 'user_updated':
      console.log(event.changes);
      break;
    case 'user_deleted':
      console.log(event.deletedAt);
      break;
  }
}

JSON Schema出力

import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';

const ProductSchema = z.object({
  id: z.string().uuid().describe('Product unique identifier'),
  name: z.string().min(1).max(255).describe('Product name'),
  price: z.number().positive().describe('Price in cents'),
  category: z.enum(['electronics', 'clothing', 'food']),
  tags: z.array(z.string()).optional(),
  metadata: z.record(z.unknown()).optional(),
});

const jsonSchema = zodToJsonSchema(ProductSchema, {
  name: 'Product',
  target: 'openApi3',
});

// 出力:
// {
//   "$schema": "http://json-schema.org/draft-07/schema#",
//   "type": "object",
//   "properties": {
//     "id": {
//       "type": "string",
//       "format": "uuid",
//       "description": "Product unique identifier"
//     },
//     "name": {
//       "type": "string",
//       "minLength": 1,
//       "maxLength": 255,
//       "description": "Product name"
//     },
//     ...
//   },
//   "required": ["id", "name", "price", "category"]
// }

メタデータAPI

// スキーマにメタデータを付与
const UserSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  name: z.string(),
}).meta({
  title: 'User',
  description: 'Represents a user in the system',
  examples: [
    { id: '123e4567-e89b-12d3-a456-426614174000', email: 'user@example.com', name: 'John Doe' },
  ],
});

// メタデータの取得
const metadata = UserSchema._def.meta;
console.log(metadata.title);       // 'User'
console.log(metadata.description); // 'Represents a user in the system'

// OpenAPI生成などに活用
function generateOpenApiSchema(schema: z.ZodType) {
  const meta = schema._def.meta ?? {};
  return {
    title: meta.title,
    description: meta.description,
    examples: meta.examples,
    // ...
  };
}

ブランド型

// 型レベルでの区別
const UserId = z.string().uuid().brand<'UserId'>();
const PostId = z.string().uuid().brand<'PostId'>();

type UserId = z.infer<typeof UserId>;
type PostId = z.infer<typeof PostId>;

// 使用例
function getUser(id: UserId): Promise<User> { ... }
function getPost(id: PostId): Promise<Post> { ... }

const userId = UserId.parse('123e4567-e89b-12d3-a456-426614174000');
const postId = PostId.parse('987fcdeb-51a2-34d6-b789-123456789abc');

getUser(userId);  // OK
getUser(postId);  // TypeScriptエラー!

フォームバリデーション

// React Hook Form との統合
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const SignUpSchema = z.object({
  email: z.string().email('有効なメールアドレスを入力してください'),
  password: z.string().min(8, '8文字以上入力してください'),
  confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
  message: 'パスワードが一致しません',
  path: ['confirmPassword'],
});

type SignUpForm = z.infer<typeof SignUpSchema>;

function SignUpPage() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<SignUpForm>({
    resolver: zodResolver(SignUpSchema),
  });

  const onSubmit = (data: SignUpForm) => {
    console.log(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('email')} />
      {errors.email && <span>{errors.email.message}</span>}

      <input type="password" {...register('password')} />
      {errors.password && <span>{errors.password.message}</span>}

      <input type="password" {...register('confirmPassword')} />
      {errors.confirmPassword && <span>{errors.confirmPassword.message}</span>}

      <button type="submit">Sign Up</button>
    </form>
  );
}

高度なパターン

再帰スキーマ

// ツリー構造
interface Category {
  id: string;
  name: string;
  children: Category[];
}

const CategorySchema: z.ZodType<Category> = z.lazy(() =>
  z.object({
    id: z.string(),
    name: z.string(),
    children: z.array(CategorySchema),
  })
);

条件付きバリデーション

const PaymentSchema = z.discriminatedUnion('method', [
  z.object({
    method: z.literal('credit_card'),
    cardNumber: z.string().regex(/^\d{16}$/),
    expiryDate: z.string().regex(/^\d{2}\/\d{2}$/),
    cvv: z.string().regex(/^\d{3,4}$/),
  }),
  z.object({
    method: z.literal('bank_transfer'),
    bankCode: z.string(),
    accountNumber: z.string(),
  }),
  z.object({
    method: z.literal('paypal'),
    email: z.string().email(),
  }),
]);

参考リンク

← 一覧に戻る