Zodバリデーション完全マスター - スキーマ駆動開発の実践
Zodバリデーション完全マスター - スキーマ駆動開発の実践
TypeScriptアプリケーション開発において、ランタイムバリデーションは避けて通れない重要な要素です。外部API、ユーザー入力、環境変数など、型安全性が保証されないデータソースに対して、適切なバリデーションを行う必要があります。
Zodは、TypeScriptファーストなスキーマバリデーションライブラリです。シンプルなAPI、優れた型推論、豊富なバリデーション機能を備え、フォームバリデーションからAPI型安全性まで幅広く活用できます。
本記事では、Zodの基本から実践的な使い方、複雑なスキーマ定義、エラーハンドリング、パフォーマンス最適化まで徹底解説します。
Zodの特徴
TypeScriptファーストな設計
import { z } from 'zod'
// スキーマ定義
const userSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
age: z.number().min(0).max(150),
})
// 型推論(自動的に型が生成される)
type User = z.infer<typeof userSchema>
// { id: number; name: string; email: string; age: number }
// バリデーション
const result = userSchema.safeParse({
id: 1,
name: 'John Doe',
email: 'john@example.com',
age: 30,
})
if (result.success) {
console.log(result.data) // 型安全なデータ
} else {
console.error(result.error) // バリデーションエラー
}
他のバリデーションライブラリとの比較
// Zodの利点
// - 型推論が強力
// - APIがシンプル
// - エラーメッセージがカスタマイズ可能
// - バンドルサイズが小さい(8KB gzipped)
// - 依存関係ゼロ
基本的なスキーマ定義
プリミティブ型
import { z } from 'zod'
// 文字列
const stringSchema = z.string()
const minLengthSchema = z.string().min(3)
const maxLengthSchema = z.string().max(100)
const emailSchema = z.string().email()
const urlSchema = z.string().url()
const uuidSchema = z.string().uuid()
const regexSchema = z.string().regex(/^[a-z]+$/)
// 数値
const numberSchema = z.number()
const integerSchema = z.number().int()
const positiveSchema = z.number().positive()
const nonNegativeSchema = z.number().nonnegative()
const minMaxSchema = z.number().min(0).max(100)
const multipleOfSchema = z.number().multipleOf(5)
// 真偽値
const booleanSchema = z.boolean()
// 日付
const dateSchema = z.date()
const minDateSchema = z.date().min(new Date('2020-01-01'))
const maxDateSchema = z.date().max(new Date('2030-12-31'))
// undefined / null
const undefinedSchema = z.undefined()
const nullSchema = z.null()
const nullableSchema = z.string().nullable() // string | null
const optionalSchema = z.string().optional() // string | undefined
オブジェクトスキーマ
// 基本的なオブジェクト
const userSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
age: z.number().optional(),
})
// ネストしたオブジェクト
const profileSchema = z.object({
user: z.object({
id: z.number(),
name: z.string(),
}),
settings: z.object({
theme: z.enum(['light', 'dark']),
notifications: z.boolean(),
}),
})
// 部分的なスキーマ(すべてoptional)
const partialUserSchema = userSchema.partial()
// 一部のフィールドのみ必須
const updateUserSchema = userSchema.partial().required({ id: true })
// 特定のフィールドを除外
const userWithoutIdSchema = userSchema.omit({ id: true })
// 特定のフィールドのみ選択
const userNameOnlySchema = userSchema.pick({ name: true, email: true })
配列とタプル
// 配列
const stringArraySchema = z.array(z.string())
const minArraySchema = z.array(z.string()).min(1) // 最低1要素
const maxArraySchema = z.array(z.string()).max(10) // 最大10要素
const nonEmptyArraySchema = z.array(z.string()).nonempty()
// タプル(固定長配列)
const tupleSchema = z.tuple([z.string(), z.number(), z.boolean()])
// [string, number, boolean]
// 可変長タプル
const variableTupleSchema = z.tuple([z.string(), z.number()]).rest(z.boolean())
// [string, number, ...boolean[]]
Enumとリテラル型
// Enum
const roleSchema = z.enum(['admin', 'user', 'guest'])
type Role = z.infer<typeof roleSchema> // 'admin' | 'user' | 'guest'
// Native Enum
enum NativeRole {
Admin = 'admin',
User = 'user',
Guest = 'guest',
}
const nativeRoleSchema = z.nativeEnum(NativeRole)
// リテラル型
const literalSchema = z.literal('hello')
const numberLiteralSchema = z.literal(42)
const booleanLiteralSchema = z.literal(true)
Union型とIntersection型
// Union型(いずれか)
const stringOrNumberSchema = z.union([z.string(), z.number()])
const discriminatedUnionSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('text'), content: z.string() }),
z.object({ type: z.literal('number'), value: z.number() }),
])
// Intersection型(両方)
const baseSchema = z.object({
id: z.number(),
createdAt: z.date(),
})
const userSchema = baseSchema.and(
z.object({
name: z.string(),
email: z.string().email(),
})
)
// { id: number; createdAt: Date; name: string; email: string }
高度なバリデーション
カスタムバリデーション
// refineメソッドでカスタムバリデーション
const passwordSchema = z
.string()
.min(8, 'Password must be at least 8 characters')
.refine((password) => /[A-Z]/.test(password), {
message: 'Password must contain at least one uppercase letter',
})
.refine((password) => /[a-z]/.test(password), {
message: 'Password must contain at least one lowercase letter',
})
.refine((password) => /[0-9]/.test(password), {
message: 'Password must contain at least one number',
})
// superRefineで複数のエラーを追加
const passwordConfirmSchema = z
.object({
password: z.string().min(8),
confirmPassword: z.string(),
})
.superRefine((data, ctx) => {
if (data.password !== data.confirmPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Passwords do not match',
path: ['confirmPassword'],
})
}
})
変換(Transform)
// 文字列を数値に変換
const stringToNumberSchema = z.string().transform((val) => parseInt(val, 10))
// 文字列をDateに変換
const stringToDateSchema = z.string().transform((val) => new Date(val))
// オブジェクトの変換
const userInputSchema = z
.object({
name: z.string(),
age: z.string(),
})
.transform((data) => ({
name: data.name.trim(),
age: parseInt(data.age, 10),
}))
// transformとrefineの組み合わせ
const emailSchema = z
.string()
.transform((val) => val.toLowerCase().trim())
.refine((val) => val.includes('@'), {
message: 'Invalid email address',
})
非同期バリデーション
// 非同期バリデーション
const uniqueEmailSchema = z.string().email().refine(
async (email) => {
const exists = await checkEmailExists(email)
return !exists
},
{
message: 'Email already exists',
}
)
// 使用例
async function validateUser(data: unknown) {
const result = await userSchema.safeParseAsync(data)
if (result.success) {
return result.data
} else {
throw result.error
}
}
条件付きバリデーション
// discriminatedUnionで条件分岐
const eventSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('click'),
element: z.string(),
x: z.number(),
y: z.number(),
}),
z.object({
type: z.literal('scroll'),
scrollTop: z.number(),
}),
z.object({
type: z.literal('resize'),
width: z.number(),
height: z.number(),
}),
])
// 動的なバリデーション
function createUserSchema(requireEmail: boolean) {
return z.object({
name: z.string(),
email: requireEmail ? z.string().email() : z.string().email().optional(),
})
}
フォームバリデーション
React Hook Formとの統合
npm install react-hook-form @hookform/resolvers
// components/UserForm.tsx
'use client'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
const userFormSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Invalid email address'),
age: z.number().min(18, 'Must be at least 18 years old').max(100),
password: z.string().min(8, 'Password must be at least 8 characters'),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
})
type UserFormData = z.infer<typeof userFormSchema>
export function UserForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<UserFormData>({
resolver: zodResolver(userFormSchema),
})
const onSubmit = async (data: UserFormData) => {
console.log('Form data:', data)
// APIリクエストなど
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label>Name</label>
<input {...register('name')} />
{errors.name && <p>{errors.name.message}</p>}
</div>
<div>
<label>Email</label>
<input type="email" {...register('email')} />
{errors.email && <p>{errors.email.message}</p>}
</div>
<div>
<label>Age</label>
<input
type="number"
{...register('age', { valueAsNumber: true })}
/>
{errors.age && <p>{errors.age.message}</p>}
</div>
<div>
<label>Password</label>
<input type="password" {...register('password')} />
{errors.password && <p>{errors.password.message}</p>}
</div>
<div>
<label>Confirm Password</label>
<input type="password" {...register('confirmPassword')} />
{errors.confirmPassword && <p>{errors.confirmPassword.message}</p>}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Submitting...' : 'Submit'}
</button>
</form>
)
}
動的フォーム
// 動的に項目を追加できるフォーム
const dynamicFormSchema = z.object({
name: z.string().min(2),
email: z.string().email(),
tags: z.array(z.string()).min(1, 'At least one tag is required'),
socialLinks: z.array(
z.object({
platform: z.enum(['twitter', 'github', 'linkedin']),
url: z.string().url(),
})
).optional(),
})
type DynamicFormData = z.infer<typeof dynamicFormSchema>
export function DynamicForm() {
const {
register,
handleSubmit,
control,
formState: { errors },
} = useForm<DynamicFormData>({
resolver: zodResolver(dynamicFormSchema),
defaultValues: {
tags: [''],
socialLinks: [],
},
})
const { fields, append, remove } = useFieldArray({
control,
name: 'socialLinks',
})
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<input {...register('name')} placeholder="Name" />
<input {...register('email')} placeholder="Email" />
{/* 動的に追加可能な項目 */}
<div>
<h3>Social Links</h3>
{fields.map((field, index) => (
<div key={field.id}>
<select {...register(`socialLinks.${index}.platform`)}>
<option value="twitter">Twitter</option>
<option value="github">GitHub</option>
<option value="linkedin">LinkedIn</option>
</select>
<input {...register(`socialLinks.${index}.url`)} placeholder="URL" />
<button type="button" onClick={() => remove(index)}>
Remove
</button>
</div>
))}
<button
type="button"
onClick={() => append({ platform: 'twitter', url: '' })}
>
Add Social Link
</button>
</div>
<button type="submit">Submit</button>
</form>
)
}
API型安全性
APIレスポンスのバリデーション
// lib/api.ts
import { z } from 'zod'
const userSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
createdAt: z.string().transform((val) => new Date(val)),
})
const usersResponseSchema = z.object({
users: z.array(userSchema),
total: z.number(),
page: z.number(),
})
type User = z.infer<typeof userSchema>
type UsersResponse = z.infer<typeof usersResponseSchema>
export async function fetchUsers(page = 1): Promise<UsersResponse> {
const response = await fetch(`https://api.example.com/users?page=${page}`)
const data = await response.json()
// バリデーション
return usersResponseSchema.parse(data)
}
// エラーハンドリング付き
export async function fetchUsersSafe(page = 1): Promise<UsersResponse | null> {
try {
const response = await fetch(`https://api.example.com/users?page=${page}`)
const data = await response.json()
const result = usersResponseSchema.safeParse(data)
if (result.success) {
return result.data
} else {
console.error('API response validation failed:', result.error)
return null
}
} catch (error) {
console.error('API request failed:', error)
return null
}
}
APIリクエストのバリデーション
// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
const createUserSchema = z.object({
name: z.string().min(2).max(100),
email: z.string().email(),
age: z.number().int().min(18).max(150),
})
export async function POST(request: NextRequest) {
try {
const body = await request.json()
// バリデーション
const validatedData = createUserSchema.parse(body)
// ユーザー作成
const user = await db.insert(users).values(validatedData).returning()
return NextResponse.json(user, { status: 201 })
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Validation failed', issues: error.errors },
{ status: 400 }
)
}
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
}
const updateUserSchema = createUserSchema.partial()
export async function PATCH(request: NextRequest) {
try {
const body = await request.json()
const validatedData = updateUserSchema.parse(body)
// ユーザー更新
const user = await db.update(users).set(validatedData).returning()
return NextResponse.json(user)
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Validation failed', issues: error.errors },
{ status: 400 }
)
}
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
}
tRPCとの統合
npm install @trpc/server @trpc/client @trpc/react-query @trpc/next
// server/trpc.ts
import { initTRPC } from '@trpc/server'
import { z } from 'zod'
const t = initTRPC.create()
export const appRouter = t.router({
// ユーザー一覧取得
getUsers: t.procedure
.input(
z.object({
page: z.number().min(1).default(1),
limit: z.number().min(1).max(100).default(10),
})
)
.query(async ({ input }) => {
const users = await db
.select()
.from(usersTable)
.limit(input.limit)
.offset((input.page - 1) * input.limit)
return { users }
}),
// ユーザー作成
createUser: t.procedure
.input(
z.object({
name: z.string().min(2).max(100),
email: z.string().email(),
age: z.number().int().min(18).max(150),
})
)
.mutation(async ({ input }) => {
const [user] = await db.insert(usersTable).values(input).returning()
return user
}),
// ユーザー更新
updateUser: t.procedure
.input(
z.object({
id: z.number(),
data: z.object({
name: z.string().min(2).max(100).optional(),
email: z.string().email().optional(),
age: z.number().int().min(18).max(150).optional(),
}),
})
)
.mutation(async ({ input }) => {
const [user] = await db
.update(usersTable)
.set(input.data)
.where(eq(usersTable.id, input.id))
.returning()
return user
}),
})
export type AppRouter = typeof appRouter
環境変数のバリデーション
// lib/env.ts
import { z } from 'zod'
const envSchema = z.object({
// 必須環境変数
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
API_URL: z.string().url(),
// オプション環境変数
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.string().transform((val) => parseInt(val, 10)).default('3000'),
// 真偽値の環境変数
ENABLE_ANALYTICS: z
.string()
.transform((val) => val === 'true')
.default('false'),
})
// 型推論
export type Env = z.infer<typeof envSchema>
// バリデーション実行
function validateEnv(): Env {
try {
return envSchema.parse(process.env)
} catch (error) {
if (error instanceof z.ZodError) {
console.error('Environment validation failed:')
console.error(error.errors)
process.exit(1)
}
throw error
}
}
// エクスポート
export const env = validateEnv()
// 使用例
// import { env } from '@/lib/env'
// console.log(env.DATABASE_URL) // 型安全にアクセス
エラーハンドリング
エラーメッセージのカスタマイズ
// デフォルトエラーメッセージの上書き
const customErrorMap: z.ZodErrorMap = (issue, ctx) => {
if (issue.code === z.ZodIssueCode.invalid_type) {
if (issue.expected === 'string') {
return { message: 'この項目は文字列で入力してください' }
}
}
if (issue.code === z.ZodIssueCode.too_small) {
if (issue.type === 'string') {
return { message: `${issue.minimum}文字以上で入力してください` }
}
}
return { message: ctx.defaultError }
}
z.setErrorMap(customErrorMap)
// 個別のスキーマでエラーメッセージを設定
const userSchema = z.object({
name: z.string().min(2, { message: '名前は2文字以上で入力してください' }),
email: z.string().email({ message: '有効なメールアドレスを入力してください' }),
age: z.number({
required_error: '年齢は必須です',
invalid_type_error: '年齢は数値で入力してください',
}).min(18, { message: '18歳以上である必要があります' }),
})
エラー情報の取得
const result = userSchema.safeParse(data)
if (!result.success) {
const errors = result.error
// すべてのエラーメッセージ
console.log(errors.errors)
// [
// { code: 'too_small', minimum: 2, type: 'string', message: '...', path: ['name'] },
// { code: 'invalid_string', validation: 'email', message: '...', path: ['email'] }
// ]
// フィールドごとのエラーメッセージ
const fieldErrors = errors.flatten().fieldErrors
console.log(fieldErrors)
// {
// name: ['名前は2文字以上で入力してください'],
// email: ['有効なメールアドレスを入力してください']
// }
// フォーマットされたエラー
console.log(errors.format())
// {
// name: { _errors: ['名前は2文字以上で入力してください'] },
// email: { _errors: ['有効なメールアドレスを入力してください'] }
// }
}
パフォーマンス最適化
スキーマの再利用
// ❌ 悪い例: 毎回スキーマを生成
function validateUser(data: unknown) {
const schema = z.object({
name: z.string(),
email: z.string().email(),
})
return schema.parse(data)
}
// ✅ 良い例: スキーマを再利用
const userSchema = z.object({
name: z.string(),
email: z.string().email(),
})
function validateUser(data: unknown) {
return userSchema.parse(data)
}
Lazy Evaluation
// 循環参照がある場合は z.lazy を使用
interface Category {
id: number
name: string
subcategories: Category[]
}
const categorySchema: z.ZodType<Category> = z.lazy(() =>
z.object({
id: z.number(),
name: z.string(),
subcategories: z.array(categorySchema),
})
)
部分的なバリデーション
// 必要な部分だけバリデーション
const userSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
profile: z.object({
bio: z.string(),
avatar: z.string().url(),
}),
})
// IDとnameだけバリデーション
const partialSchema = userSchema.pick({ id: true, name: true })
// profileを除外
const withoutProfileSchema = userSchema.omit({ profile: true })
まとめ
Zodを使ったスキーマ駆動開発の主なポイントは以下の通りです。
- 型安全性: スキーマから自動的に型が推論される
- バリデーション: ランタイムで型を検証し、安全性を保証
- エラーハンドリング: 詳細なエラー情報とカスタマイズ可能なメッセージ
- フォーム統合: React Hook Formとシームレスに統合
- API型安全性: リクエスト/レスポンスの型を保証
Zodを活用することで、TypeScriptの型システムとランタイムバリデーションを統合し、より安全で保守しやすいアプリケーションを構築できます。スキーマ駆動開発を実践し、型安全性の恩恵を最大限に活用しましょう。