Zod v4の概要
Zod v4は、TypeScript向けスキーマバリデーションライブラリのメジャーバージョンです。パフォーマンス改善、より良いエラーメッセージ、新しいユーティリティが追加されています。
パフォーマンス改善
| 項目 | Zod v3 | Zod v4 | 改善率 |
|---|---|---|---|
| オブジェクトパース速度 | 10.2μs | 6.8μs | 33% faster |
| 配列パース速度(1000要素) | 850μs | 520μs | 39% faster |
| バンドルサイズ(gzip) | 14KB | 10KB | 29% 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(),
}),
]);