概要
Zod でネストされたオブジェクト・配列の型安全なバリデーションと、transform による値変換・refine / superRefine によるクロスフィールド検証を組み合わせる。z.object() の基本から一歩進んだスキーマ設計パターンを示す。
インストール
npm install zod
実装
ネストオブジェクト
import { z } from "zod";
// ネストしたオブジェクトスキーマ
const addressSchema = z.object({
zipCode: z.string().regex(/^\d{7}$/, "郵便番号は7桁の数字"),
prefecture: z.string().min(1, "都道府県は必須"),
city: z.string().min(1, "市区町村は必須"),
});
const userSchema = z.object({
name: z.string().min(1, "名前は必須"),
age: z.number().int().min(0).max(150),
address: addressSchema, // ネスト
});
type User = z.infer<typeof userSchema>;
// { name: string; age: number; address: { zipCode: string; prefecture: string; city: string } }
配列バリデーション
// 配列の要素にスキーマを適用
const orderSchema = z.object({
items: z
.array(
z.object({
productId: z.string().uuid(),
quantity: z.number().int().min(1, "数量は1以上"),
price: z.number().nonnegative(),
})
)
.min(1, "1件以上の商品が必要"),
note: z.string().max(200).optional(),
});
// バリデーション
const result = orderSchema.safeParse({
items: [{ productId: "550e8400-e29b-41d4-a716-446655440000", quantity: 2, price: 1000 }],
});
transform(値変換)
// 入力は string、出力は number に変換
const priceSchema = z.object({
// フォームから来た文字列を数値に変換
price: z
.string()
.regex(/^\d+$/, "数値のみ")
.transform((val) => Number(val)),
// 前後の空白をトリム
name: z.string().transform((val) => val.trim()),
// YYYY-MM-DD 文字列を Date に変換
publishedAt: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, "YYYY-MM-DD 形式")
.transform((val) => new Date(val)),
});
type PriceInput = z.input<typeof priceSchema>;
// { price: string; name: string; publishedAt: string }
type PriceOutput = z.output<typeof priceSchema>;
// { price: number; name: string; publishedAt: Date }
refine(単フィールドカスタム検証)
const passwordSchema = z.object({
password: z
.string()
.min(8, "8文字以上")
.refine(
(val) => /[A-Z]/.test(val),
"大文字を1文字以上含めてください"
)
.refine(
(val) => /[0-9]/.test(val),
"数字を1文字以上含めてください"
),
});
superRefine(クロスフィールド検証)
// パスワード確認のようなフィールド間の検証
const signUpSchema = z
.object({
email: z.string().email(),
password: z.string().min(8),
confirmPassword: z.string(),
startDate: z.string(),
endDate: z.string(),
})
.superRefine((data, ctx) => {
// パスワード一致チェック
if (data.password !== data.confirmPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "パスワードが一致しません",
path: ["confirmPassword"],
});
}
// 日付の前後チェック
if (new Date(data.startDate) >= new Date(data.endDate)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "終了日は開始日より後にしてください",
path: ["endDate"],
});
}
});
Route Handler でのバリデーション例
// app/api/orders/route.ts
import { NextResponse } from "next/server";
import { z } from "zod";
const createOrderSchema = z.object({
items: z.array(z.object({
productId: z.string().uuid(),
quantity: z.number().int().min(1),
})).min(1),
couponCode: z.string().optional(),
});
export async function POST(request: Request) {
const body = await request.json();
const result = createOrderSchema.safeParse(body);
if (!result.success) {
return NextResponse.json(
{ error: result.error.flatten() },
{ status: 400 }
);
}
// result.data は型安全
const { items, couponCode } = result.data;
return NextResponse.json({ items, couponCode, status: "created" }, { status: 201 });
}
ポイント
- ネストオブジェクトは
z.object()をそのまま入れ子にするだけ。スキーマを変数に分けてz.infer<>で型を取り出せる transformは入力と出力の型を変える。フォーム(文字列)→ DB(数値/Date)の変換に使う。z.input<>とz.output<>で入出力の型を区別できるrefineは単一フィールドのカスタムチェック、superRefineは複数フィールドを参照したクロスフィールドチェックに使う。どちらもsafeParseの.error.flatten()でフィールドごとのエラーを取り出せるzod-discriminated-unionとの使い分け: リクエスト種別によってスキーマ全体が変わる場合は discriminated union、同一スキーマ内の複雑なバリデーションはこのパターンerror.flatten()は{ fieldErrors: {...}, formErrors: [...] }形式でエラーを構造化してくれる。API レスポンスとクライアントへの表示に使いやすい