web-forms-zod-validation について
TypeScript 開発者向けに、Zod スキーマの検証、型推論、変換、合成、v4 移行を解説するガイドです。アプリケーション境界でのデータ検証に役立ちます。
主な機能
- 01安全なパースとエラー整形
- 02z.infer による型導出
- 03細かな条件とフィールド間検証
- 04変換と入力・出力型
- 05スキーマ合成とユニオン
- 06Zod v4 移行ガイド
活用シーン
- 01フォーム入力を検証して項目エラーを表示
- 02API 応答や URL パラメーターの確認
- 03条件付きバリアントや複数項目ルールの定義
- 04文字列を数値や日付に変換
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ agents-inc/ skills --skill web-forms-zod-validationインストール後、Claude Code で/web-forms-zod-validationを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
metadata.yamlreference.mdexamples/advanced-patterns.mdcore.mdtransforms.md詳細
Zod スキーマ検証のパターン
クイックガイド: スキーマを一度宣言し、
z.inferでそこから TypeScript の型を導出すれば、ルールと型が食い違うことはありません。無効な入力が想定される場合はsafeParseが結果オブジェクトを返し、バグがある場合はparseが例外をスローします。組み込みのチェックでは表現できないルールにはrefineとsuperRefineを使います。また、transformは検証中に値を変換するため、z.inputとz.outputが分かれます。v4 では文字列形式がトップレベルに移動し(z.email()、z.url()、z.iso.*)、v3 のメソッドチェーンは削除されたのではなく非推奨になりました。flatten()、format()、merge()には代替手段があり、移行項目の全一覧は reference.md に記載されています。
詳細リソース:
- examples/core.md — スキーマ定義、安全な解析、エラーの整形、ユニオン、合成、非同期の追加検証
- examples/transforms.md — 変換、強制変換、パイプチェーン、クエリパラメーター
- examples/advanced-patterns.md — ブランド型、
.catch()によるフォールバック、読み取り専用、再帰スキーマ - reference.md — 判断ツリー、メソッドの検索、具体的なアンチパターン、v4 移行ガイド
該当する方針
- スキーマで値をチェックし、受け取った値をそのまま返す場合 — 必要な型ヘルパーは
z.inferだけで、解析された値の形状は呼び出し側が渡した値と同じです。examples/core.md を参照してください。 - スキーマでチェックしながら値を変換する場合 —
transform、coerce、またはdefaultを使うと入力型と出力型が異なります。そのため、検証前のデータ型を受け取る関数では、パラメーターにz.input、戻り値にz.outputを指定します。examples/transforms.md を参照してください。
<critical_requirements>
Zod スキーマを書く前に
無効な入力が想定される場合は、safeParse を使ってください。 例外をスローする代わりに結果オブジェクトを返すため、失敗は catch ではなく分岐として扱えます。また、result.error.issues にはフォームで必要なフィールドパスが含まれています。無効な値がバグを意味する設定や内部データには、parse を使ってください。
z.infer<typeof schema> で型を導出してください。 スキーマと並べて手書きのインターフェースを置くと、同じものを二重に宣言することになり、やがて両者に食い違いが生じます。その結果、スキーマで検証される範囲を超えた型を宣言してしまいます。
信頼できないデータが入ってくる場所で検証してください — API レスポンス、フォーム入力、設定、URL パラメーターなどです。形状の変更を境界で検出すれば、どのフィールドが変わったかがわかります。後で検出すると、数フレーム離れた場所で未定義のプロパティとして表面化します。
検証の制限値には名前を付けてください。 .min(MIN_USERNAME_LENGTH) とすれば、その数値の用途が明確になり、エラーメッセージにも同じ定数を使うことで、両者の不一致を防げます。
</critical_requirements>
自動検出: zod, z.object, z.infer, z.input, z.output, safeParse, safeParseAsync, parse, parseAsync, refine, superRefine, ctx.addIssue, transform, discriminatedUnion, z.coerce, z.pipe, z.catch, z.brand, z.lazy, z.email, z.url, z.uuid, z.iso, z.flattenError, z.treeifyError, z.strictObject, z.looseObject, ZodError
対象:
- 信頼境界を越えるデータの検証と、失敗したフィールドの報告
- 検証ルールからの TypeScript 型の導出
- フィールド間のルール、条件付きの形状、判別可能なバリアント
- 検証中の値の変換 — 文字列から数値、ISO 文字列から
Date - 1 つのレコードの読み取り、作成、更新用のスキーマの合成
別の場所で扱うもの:
- スキーマとフォームの接続 — フォームライブラリは独自のアダプターまたはバリデーター用スロットを通じてスキーマを受け取ります。その接続方法は、それを管理する側で決まります。
- データの取得元 — スキーマは渡された値を検証するものであり、それ自体では I/O を行いません。
- 永続化スキーマ — ランタイムバリデーターとテーブル定義は、同じレコードを記述していても別の成果物です。一方から他方を生成することは、そのツールの役割です。
TypeScript はコンパイルするコードをチェックし、スキーマは受け取るデータをチェックします。両者が交わるのは境界です。スキーマから型を導出するのは、誤りうるものを 1 つだけにするためです。
const UserSchema = z.object({ name: z.string(), email: z.email() });
type User = z.infer<typeof UserSchema>;
逆のやり方、つまりインターフェースと、それと並行して管理するスキーマでは、インターフェースにフィールドを追加してスキーマへの追加を忘れても、型チェックはどこでも通る一方で、何も検証されません。
その帰結として、スキーマを使うべきでない場面もわかります。すでに境界を越えたデータは検証済みです。コンパイラーがすでに管理している関数の中で再検証しても得るものはなく、呼び出しごとにパースのコストがかかります。
コアパターン
パターン 1:名前付きの制限値を持つスキーマ
定数をチェックとメッセージの両方で使うため、制限値を変更すれば両方に反映されます。
const MIN_USERNAME_LENGTH = 3;
const MAX_USERNAME_LENGTH = 50;
const UserSchema = z.object({
username: z
.string()
.min(
MIN_USERNAME_LENGTH,
`Username must be at least ${MIN_USERNAME_LENGTH} characters`,
)
.max(
MAX_USERNAME_LENGTH,
`Username cannot exceed ${MAX_USERNAME_LENGTH} characters`,
),
email: z.email("Invalid email format"),
});
type User = z.infer<typeof UserSchema>;
完全なコード:examples/core.md パターン 1
パターン 2:safeParse とエラーのフォーマット
結果は success による判別可能なユニオンなので、失敗の分岐ではエラーに、成功の分岐では型付けされたデータに絞り込まれます。
const result = UserSchema.safeParse(data);
if (!result.success) {
const { fieldErrors } = z.flattenError(result.error);
return { success: false, errors: fieldErrors };
}
return { success: true, user: result.data };
z.flattenError は、フラットなフォームで必要となる、1 階層のフィールドとメッセージの対応を返します。
z.treeifyError は、それより深い構造のネストを保持します。
完全なコード:examples/core.md パターン 2
パターン 3:リファインメントとフィールド間のルール
refine は 1 つの値に条件を追加します。superRefine はオブジェクト全体を参照し、エラーをどのパスに紐付けるかを選択できます。これにより、不一致のメッセージをユーザーが修正すべきフィールドに表示できます。
const PasswordFormSchema = z
.object({
password: z.string().min(MIN_PASSWORD_LENGTH),
confirmPassword: z.string(),
})
.superRefine((data, ctx) => {
if (data.password !== data.confirmPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Passwords do not match",
path: ["confirmPassword"],
});
}
});
完全なコード:examples/core.md パターン 5
パターン 4:変換と入力/出力の分離
スキーマに変換を加えると、2 種類の型ができます。z.infer が返すのは出力型なので、生データを受け取る関数の引数には z.input を指定します。
const DateSchema = z.iso.datetime().transform((str) => new Date(str));
type DateInput = z.input<typeof DateSchema>; // string
type DateOutput = z.output<typeof DateSchema>; // Date
.transform() は同じスキーマ上のほかのすべてのチェックの後に実行されます。そのため、変換後の値を検査するルールは、変換の前ではなく .pipe() の後に置きます。
コード全体:examples/transforms.md のパターン 7、およびパイプについてのパターン 9
パターン 5:判別可能なユニオン
バリエーション間でリテラルフィールドを共有している場合、そのフィールドを指定すると、Zod はすべてのバリエーションではなく、該当する分岐だけをチェックできます。
const NotificationSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("email"), email: z.email(), subject: z.string() }),
z.object({ type: z.literal("sms"), phone: z.string(), message: z.string() }),
z.object({
type: z.literal("push"),
deviceId: z.string(),
title: z.string(),
}),
]);
通常の z.union は各メンバーをすべて試し、失敗をまとめて報告するため、「無効な入力」と表示されます。判別可能な形式ではバリエーションとその内部のフィールドを指定でき、switch で型を絞り込めます。
コード全体:examples/core.md のパターン 4
パターン 6:合成
ベースとなるスキーマから読み取り、作成、更新の各形状を導出すれば、ベースに追加したフィールドが 3 つすべてに反映されます。
const UserSchema = BaseEntitySchema.extend({
email: z.email(),
name: z.string(),
});
const CreateUserSchema = UserSchema.omit({
id: true,
createdAt: true,
updatedAt: true,
});
const UpdateUserSchema = CreateUserSchema.partial();
const UserSummarySchema = UserSchema.pick({ id: true, name: true });
.refine() がすでに適用されたスキーマでは .extend() を使えないため、先に拡張し、最後に絞り込みます。
コード全体:examples/core.md のパターン 3
パターン 7:文字列ソースの強制変換
URL パラメーターやフォームフィールドは、どのような値を表していても文字列として届きます。z.coerce はチェックの前に変換するため、ルールを対象の型に合わせて記述できます。
const PaginationSchema = z.object({
page: z.coerce.number().int().positive().default(DEFAULT_PAGE),
limit: z.coerce
.number()
.int()
.positive()
.max(MAX_LIMIT)
.default(DEFAULT_LIMIT),
});
z.coerce.boolean() は Boolean(value) なので、空でない文字列はすべて true になります。"false" も例外ではありません。文字列が意図を表す場合は z.stringbool() を使います。
コード全体:examples/transforms.md のパターン 8
パターン 8:optional、nullable、nullish、default
値がない場合についての主張は 4 種類あります。意味に応じて選ぶことで、型を正確に保てます。
const ProfileSchema = z.object({
name: z.string(), // required
bio: z.string().optional(), // may be omitted — string | undefined
avatar: z.url().nullable(), // explicitly empty — string | null
nickname: z.string().nullish(), // either — string | null | undefined
theme: z.string().default("light"), // absent becomes "light" — always string
});
.default() はキーが欠けている場合に値を補うため、出力型は非オプショナルのままですが、入力型はそうなりません。これも z.input と z.output が異なる理由のひとつです。
<red_flags>
注意すべき点
実行時に問題が起きます:
- ユーザー入力に対する
parse— 想定されるケースでも例外がスローされます。また、例外を受け取る catch ブロックでは、error.issuesを読み取れたはずの分岐をすでに通り過ぎています。safeParseを使います。 - 非同期のリファインメントを同期的にパース — Zod は待機せず、例外をスローします。これを含むスキーマには
parseAsyncまたはsafeParseAsyncを使います。 .refine()がすでに適用されたスキーマに対する.extend()— リファインメント済みのスキーマでは、このメソッドを使えなくなります。先に拡張し、最後に絞り込みます。- v4:
.refine(fn, (val) => ({ message }))— 第 2 引数に関数を渡す形式は廃止されています。メッセージが値に依存する場合はsuperRefineを使います。 - v4:引数が 1 つの
z.record(valueSchema)— 現在はキーと値の両方のスキーマが必要です。
予想外の動作:
- 未知のキーは拒否されず、削除されます。
z.strictObject()は未知のキーを拒否し、z.looseObject()は保持します。通常のオブジェクトスキーマは何も知らせずに削除するため、名前が変更された API フィールドを見落とすおそれがあります。 z.coerce.boolean()は"false"をtrueに変換します。また、z.coerce.date()は、意図していない日付に解釈される文字列を含め、new Date()が受け付けるものをすべて受け付けます。入力が ISO 形式であるべき場合は、z.iso.datetime()を使うとそのことを明示でき、それ以外は拒否されます。z.email()は空文字列を拒否するため、""を送信するオプショナルフィールドは失敗します。明示的に許可するか、パース前に""をundefinedに正規化してください。- 読み取るキーがない場合、
superRefineのルールは失敗するのではなく、スキップされます。オブジェクトは有効と判定されます。 - チェックにメッセージがない場合、デフォルトの
"Invalid input"メッセージがそのままユーザーに表示されます。 - v4:
superRefineからctx.pathが削除されました。ctx.addIssue({ path: [...] })は引き続き使えます。 - v4 での置き換え:
.flatten()→z.flattenError()、.format()→z.treeifyError()、.merge()→.extend()。
これらのうち、よくある問題の修正前・修正後のコードは reference.md にあります。
</red_flags>