zod-validation-expert について
TypeScript で Zod スキーマを定義し、実行時の解析と検証を行うためのスキルです。型推論、カスタムルール、変換、エラー処理のほか、React Hook Form や Next.js との連携も扱います。
主な機能
- 01スキーマ定義と型推論
- 02基本・複合バリデーション
- 03カスタム検証と変換
- 04解析とエラー整形
- 05React Hook Form 連携
- 06環境変数の検証
活用シーン
- 01API 入力とフォームの検証
- 02スキーマからの TypeScript 型推論
- 03環境変数の確認と変換
- 04フィールド間の検証ルール追加
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ sickn33/ agentic-awesome-skills --skill zod-validation-expertインストール後、Claude Code で/zod-validation-expertを入力して読み込まれたか確認します。
詳細
Zod バリデーションエキスパート
本番環境で使える Zod のエキスパートです。開発者が型安全なスキーマ定義とバリデーションロジックを構築できるよう支援します。Zod の基本(プリミティブ、オブジェクト、配列、レコード)、型推論(z.infer)、複雑なバリデーション(.refine、.superRefine)、変換(.transform)、そして最新の TypeScript エコシステム(React Hook Form、Next.js API Routes / App Router Actions、tRPC、環境変数)との連携に精通しています。
このスキルを使う場面
- API 入力やフォーム向けの TypeScript バリデーションスキーマを定義するとき
- 環境変数のバリデーション(
process.env)を設定するとき - Zod を React Hook Form(
@hookform/resolvers/zod)と連携するとき - 実行時バリデーションスキーマから TypeScript の型を抽出または推論するとき
- 複雑なバリデーションルール(フィールド間のバリデーション、非同期バリデーションなど)を記述するとき
- 入力データを変換するとき(文字列から Date への変換、文字列から数値への強制変換など)
- エラーメッセージの形式を統一するとき
バージョンと認可の範囲
エラーマップと書式設定の例では、Zod 3 の規約を使用しています。Zod 4 にコピーする前に、インストールされているメジャーバージョンと移行ガイドを確認してください。入力のパースでは呼び出し元の認証や、role/published などのフィールドに対する認可は行われません。これらはサーバー上で別途適用し、入力サイズにも上限を設けてください。z.coerce.boolean() は JavaScript の真偽値変換を使用します。空でない文字列 "false" は true になります。Zod coercion を参照してください。
基本概念
Zod を使う理由
Zod を使うと、TypeScript のインターフェースと実行時バリデーションスキーマの両方を記述する重複をなくせます。スキーマを一度定義すれば、Zod が静的な TypeScript 型を推論します。Zod は単なるバリデーションではなく、パースを行うものである点に注意してください。safeParse と parse は、デフォルトで未知のキーを取り除いた、クリーンで型付けされたデータを返します。
スキーマの定義と推論
プリミティブと強制変換
import { z } from "zod";
// Basic primitives
const stringSchema = z.string().min(3).max(255);
const numberSchema = z.number().int().positive();
const dateSchema = z.date();
// Coercion (automatically casting inputs before validation)
// Highly useful for FormData in Next.js Server Actions or URL queries
const ageSchema = z.coerce.number().min(18); // "18" -> 18
const activeSchema = z.coerce.boolean(); // "true" -> true
const dobSchema = z.coerce.date(); // "2020-01-01" -> Date object
オブジェクトと型推論
const UserSchema = z.object({
id: z.string().uuid(),
username: z.string().min(3).max(20),
email: z.string().email(),
role: z.enum(["ADMIN", "USER", "GUEST"]).default("USER"),
age: z.number().min(18).optional(), // Can be omitted
website: z.string().url().nullable(), // Can be null
tags: z.array(z.string()).min(1), // Array with at least 1 item
});
// Infer the TypeScript type directly from the schema
// No need to write a separate `interface User { ... }`
export type User = z.infer<typeof UserSchema>;
高度な型
// Records (Objects with dynamic keys but specific value types)
const envSchema = z.record(z.string(), z.string()); // Record<string, string>
// Unions (OR)
const idSchema = z.union([z.string(), z.number()]); // string | number
// Or simpler:
const idSchema2 = z.string().or(z.number());
// Discriminated Unions (Type-safe switch cases)
const ActionSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("create"), id: z.string() }),
z.object({ type: z.literal("update"), id: z.string(), data: z.any() }),
z.object({ type: z.literal("delete"), id: z.string() }),
]);
パースとバリデーション
parse と safeParse の比較
const schema = z.string().email();
// ❌ parse: Throws a ZodError if validation fails
try {
const email = schema.parse("invalid-email");
} catch (err) {
if (err instanceof z.ZodError) {
console.error(err.issues);
}
}
// ✅ safeParse: Returns a result object (No try/catch needed)
const result = schema.safeParse("[email protected]");
if (!result.success) {
// TypeScript narrows result to SafeParseError
console.log(result.error.format());
// Early return or throw domain error
} else {
// TypeScript narrows result to SafeParseSuccess
const validEmail = result.data; // Type is `string`
}
バリデーションのカスタマイズ
カスタムエラーメッセージ
const passwordSchema = z.string()
.min(8, { message: "Password must be at least 8 characters long" })
.max(100, { message: "Password is too long" })
.regex(/[A-Z]/, { message: "Password must contain at least one uppercase letter" })
.regex(/[0-9]/, { message: "Password must contain at least one number" });
// Global custom error map (useful for i18n)
z.setErrorMap((issue, ctx) => {
if (issue.code === z.ZodIssueCode.invalid_type) {
if (issue.expected === "string") return { message: "This field must be text" };
}
return { message: ctx.defaultError };
});
リファインメント(カスタムロジック)
// Basic refinement
const passwordCheck = z.string().refine((val) => val !== "password123", {
message: "Password is too weak",
});
// Cross-field validation (e.g., password matching)
const formSchema = z.object({
password: z.string().min(8),
confirmPassword: z.string()
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"], // Sets the error on the specific field
});
変換
// Change data during parsing
const stringToNumber = z.string()
.transform((val) => parseInt(val, 10))
.refine((val) => !isNaN(val), { message: "Not a valid integer" });
// Now the inferred type is `number`, not `string`!
type TransformedResult = z.infer<typeof stringToNumber>; // number
連携パターン
React Hook Form
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const loginSchema = z.object({
email: z.string().email("Invalid email address"),
password: z.string().min(6, "Password must be 6+ characters"),
});
type LoginFormValues = z.infer<typeof loginSchema>;
export function LoginForm() {
const { register, handleSubmit, formState: { errors } } = useForm<LoginFormValues>({
resolver: zodResolver(loginSchema)
});
const onSubmit = (data: LoginFormValues) => {
// data is fully typed and validated
// Pass validated credentials to the authorized login operation; never log passwords.
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("email")} />
{errors.email && <span>{errors.email.message}</span>}
{/* ... */}
</form>
);
}
Next.js Server Actions
"use server";
import { z } from "zod";
// FormData values can be strings or Files. Validate each expected field explicitly.
const createPostSchema = z.object({
title: z.string().min(3),
content: z.string().optional(),
published: z.enum(["on"]).optional().transform(value => value === "on"), // unchecked => false
});
export async function createPost(prevState: any, formData: FormData) {
// Convert FormData to standard object using Object.fromEntries
const rawData = Object.fromEntries(formData.entries());
const validatedFields = createPostSchema.safeParse(rawData);
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
};
}
// Proceed with validated database operation
const { title, content, published } = validatedFields.data;
// ...
return { success: true };
}
環境変数
// Make environment variables strictly typed and fail-fast
import { z } from "zod";
const envSchema = z.object({
DATABASE_URL: z.string().url(),
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
PORT: z.coerce.number().default(3000),
API_KEY: z.string().min(10),
});
// Fails the build immediately if env vars are missing or invalid
const env = envSchema.parse(process.env);
export default env;
ベストプラクティス
- ✅ 推奨: 関心事の分離を保つため、スキーマは使用するコンポーネントや API ルートの近くに配置してください。
- ✅ 推奨: TypeScript のインターフェースを手動で重複管理する代わりに、常に
z.infer<typeof Schema>を使用してください。 - ✅ 推奨:
try/catchブロックがあちこちに散らばるのを避け、堅牢なエラー処理のために TypeScript の制御フローによる型の絞り込みを活用できるよう、parseよりsafeParseを優先してください。 - ✅ 推奨:
URLSearchParamsやFormDataからデータを受け取る場合はz.coerceを使用してください。また、z.coerce.boolean()は"false"/"off"という標準的な文字列を、カスタム前処理なしでは予期しない形で変換する点に注意してください。 - ✅ 推奨:
ZodErrorオブジェクトに.flatten()または.format()を使用し、フロントエンドで利用できるシリアライズ可能な、読みやすいエラーを簡単に抽出してください。 - ❌ 非推奨: 作成時と更新時でフィールドの型や制約が異なる場合、更新用スキーマを
.partial()だけに頼らず、個別のスキーマを定義してください。 - ❌ 非推奨: オブジェクトレベルのフィールド間バリデーションで
.refine()または.superRefine()を使う際に、pathオプションを渡し忘れないでください。渡さないと、エラーが正しい入力フィールドに紐付きません。
トラブルシューティング
問題: Type instantiation is excessively deep and possibly infinite.
解決策: 極端に再帰が深いスキーマ(たとえば、深くネストされた自己参照スキーマ)で発生します。再帰構造には z.lazy(() => NodeSchema) を使用し、推論だけに頼らず、TypeScript の基本型を明示的に定義してください。
問題: .optional() を使用すると、空文字列が検証を通過します。
解決策: .optional() が許可するのは undefined であり、空文字列ではありません。空文字列を「値なし」として扱う場合は、.or(z.literal("")) を使用するか、次のように前処理してください:z.string().transform(v => v === "" ? undefined : v).optional()。
制限事項
- このスキルは、タスクが上記の説明に明確に該当する場合にのみ使用してください。
- 出力を、環境固有の検証、テスト、専門家によるレビューの代替として扱わないでください。
- 必要な入力、権限、安全上の境界、または成功基準が不足している場合は、作業を中断して確認してください。