Zodの入力バリデーションをClaude Codeに任せて型安全にする手順
フォーム・API・環境変数・WebhookをZodで実行時検証する型安全パターンを、Claude Codeへの依頼文ごと一人称で解説。safeParseとparseの使い分けも。
request.json() の戻り値に as User と書いた瞬間、僕はバリデーションをしたつもりでいました。
でも本番で落ちたんです。フロントが送ってきた age が、なぜか文字列の "25" だった。型注釈は「こうあってほしい」という願望でしかなくて、実行時には何のチェックもしていなかった。TypeScriptの型はビルドが終わった瞬間に消えるので、外から来たJSONには一切効きません。これを身をもって知ってから、僕は外部入力すべてをZodに通すようになりました。
そしてもう一つ気づいたのが、Zodのスキーマ書きはClaude Codeに丸投げできる、という事実です。項目・型・制約・エラー文を並べる単純作業なので、指示さえ正確なら人間より速く正確に書いてくれる。この記事は、そのやり方を実コードと依頼文つきでまとめたものです。
この記事の要点
- TypeScriptの型は実行時に消える。外部入力(フォーム・API・環境変数・Webhook)は必ずZodで実行時検証してから使う。
- 失敗を400で返したいフォームやAPIは
safeParse、起動を止めたい環境変数はparse、と使い分ける。 - スキーマは入口ごとに分ける。共通化するのは
emailSchemaのような小さな部品だけにすると保守が楽。 - Claude Codeには「項目・必須/任意・文字数・エラー文・使う境界」を表で渡すと、再利用しやすいスキーマとテストまで出てくる。
z.coerceとtransformは便利だが事故りやすい。入力元が文字列だと確定している場所に限定する。
TypeScriptの型があるのに、なぜZodがいるのか
エディタの中ではTypeScriptは最強です。補完も効くし、間違った代入は赤線が出る。でもその安心感は、コンパイルが終わった世界には持ち込めません。ブラウザのフォーム、外部API、Webhook、環境変数、DBに保存する直前の値——これらは実行時にはただの unknown、つまり「形が分からない値」です。
ここで効くのが実行時バリデーション、要するに「動いている最中に値の形を確かめる検証」です。Zodはスキーマ(入力データの設計図)を書くライブラリで、書いた設計図からTypeScriptの型も取り出せます。検証と型定義が一本化されるので、「型はこう、検証は別ファイル」みたいな二重管理が消えます。
全体の流れはこう考えると迷いません。
unknown input
-> Zod schema
-> safeParse
-> typed data
-> business logic
-> response schema
-> client
大事なのは、業務ロジックの手前で必ず unknown を検証済みデータに変換することです。型を先に信じるのではなく、Zodで境界を固めてから型を使う。順番が逆になると、冒頭の僕みたいに本番で転びます。
なぜZodはClaude Codeと相性がいいのか
バリデーションって、突き詰めると退屈な列挙作業です。「nameは必須・80文字まで・前後の空白は削る」「emailはメール形式」「planはtrial/team/enterpriseのどれか」——こういうのを項目ごとに延々と書く。人間がやると集中力が切れて、3つ目あたりで max を付け忘れます。
ここがClaude Codeの得意分野です。曖昧に「いい感じにバリデーションして」と投げると的外れなものが返りますが、境界を指定すると精度が跳ね上がります。「フォーム送信前、Next.jsのRoute Handler、環境変数、Webhookのpayload、DB保存前で使う」と使う場所を区切って渡すと、それぞれに合ったスキーマとテストまでまとめて提案してくれる。
僕の体感だと、依頼文に入れる情報の質がそのまま出力の質になります。後の章で実際の依頼文を全文載せますが、ポイントは「項目名・必須か任意か・文字数・選択肢・エラー文・使う境界」を具体で渡すこと。これだけで手戻りが激減します。
ユースケース別に「どこを守るか」を決める
スキーマは一個作って使い回す、という発想だと早晩破綻します。入力元によって、厳しくすべき場所と緩めていい場所が変わるからです。僕はこの表を作って、そのままClaude Codeに渡しています。
| ユースケース | 入口 | Zodで守ること |
|---|---|---|
| フォーム | ブラウザからの入力 | 空文字、メール形式、文字数、同意チェック |
| API request/response | request.json() と返却値 | 不正なJSON、過剰なプロパティ、レスポンス契約 |
| 環境変数 | process.env | 起動時の設定漏れ、URL形式、ポート番号 |
| Webhook payload | 外部サービスからのPOST | 署名検証後のイベント種別、必須ID、金額 |
| DB保存前 | 保存直前の値 | アプリ内で変換した後も保存できる形か |
一度やらかしたのが、「フォームとAPIで同じスキーマを完全共有して」と頼んだことです。結果、画面でしか使わない一時項目とサーバー固有の項目が一つのスキーマに混ざって、後から分離するのに半日かかりました。共有していいのはメール形式やID形式みたいな小さな部品だけ。入口ごとのスキーマは分けたほうが、結局メンテが楽です。
フォーム実装そのものはReact Hook Form実践ガイドに分けて書いています。API全体を型安全に組みたいならtRPC型安全API開発も読むと、Zodをどこに置くかの地図が見えてきます。
まずは基本のスキーマを一つ書く
最初の例は問い合わせフォームにします。z.infer は「スキーマからTypeScript型を取り出す仕組み」で、これがあるおかげで検証ルールと型が常に一致します。
// src/lib/schemas/contact.ts
import { z } from "zod";
export const contactFormSchema = z.object({
name: z
.string()
.trim()
.min(1, "お名前を入力してください")
.max(80, "お名前は80文字以内で入力してください"),
email: z
.string()
.trim()
.email("メールアドレスの形式が正しくありません"),
plan: z.enum(["trial", "team", "enterprise"]),
message: z
.string()
.trim()
.min(10, "相談内容は10文字以上で入力してください")
.max(2000, "相談内容は2000文字以内で入力してください"),
agreedToPolicy: z
.boolean()
.refine((value) => value, "プライバシーポリシーへの同意が必要です"),
});
export type ContactFormInput = z.infer<typeof contactFormSchema>;
地味だけど効くのが name と message の trim() です。これがないと、半角スペースだけの「お名前」が min(1) をすり抜けます。空白だけの応募が通報のように届いて気づきました。あと plan のような選択肢は迷わず z.enum にする。string で受けて後からif文で比較するより、受け取った瞬間に許可値だけへ絞り込むほうが、抜け漏れが起きません。
Claude Codeに作らせるときは「Zod v4前提」「エラーメッセージは日本語」「フォーム入力用なのでDBの id や createdAt は含めない」と書き添えます。これを言わないと、親切心で id を足してきたりします。
safeParseで失敗をHTTPエラーに変える
parse は失敗すると例外を投げます。だから「失敗したら起動ごと止めたい」設定ファイルや環境変数に向いています。一方 safeParse は成功/失敗をオブジェクトで返すので、「400 Bad Requestとして返したい」フォームやAPIではこちらが扱いやすい。ここを混同して、ユーザー入力に parse を使うと、入力ミスのたびに500エラーになります。
次のヘルパーは、Zodの失敗を画面やAPIで使いやすい配列に整形するものです。これを一個用意しておくと、全Route Handlerで同じ形のエラーを返せます。
// src/lib/validation.ts
import { z } from "zod";
export type ValidationProblem = {
path: string;
message: string;
};
export function validateInput<TSchema extends z.ZodTypeAny>(
schema: TSchema,
input: unknown,
):
| { ok: true; data: z.infer<TSchema> }
| { ok: false; status: 400; errors: ValidationProblem[] } {
const result = schema.safeParse(input);
if (!result.success) {
return {
ok: false,
status: 400,
errors: result.error.issues.map((issue) => ({
path: issue.path.join(".") || "_root",
message: issue.message,
})),
};
}
return { ok: true, data: result.data };
}
このヘルパーがあると、Claude Codeへの指示が一行で済みます。「すべてのRoute Handlerで validateInput を通してから業務ロジックを呼んで」。レビューの観点もはっきりします。request.json() の戻り値は unknown のまま扱い、Zodを通るまで信用しない——このルールをチームで共有する土台になります。
エラーの戻り値を画面側でどう見せるかは、エラーハンドリング設計の実践ガイドに整理しています。バリデーション失敗を例外として上に飛ばすか、戻り値で扱うかの線引きはセットで考えると楽です。
Next.js Route Handlerでrequest/responseを両方検証する
APIは入力だけ守れば十分、と思いがちですが、返却値も検証すると事故が一段減ります。たとえば外部サービス連携の後で status が想定外の文字列になったとき、レスポンススキーマが先に気づいてくれる。
// app/api/contact/route.ts
import { NextResponse } from "next/server";
import { z } from "zod";
import {
contactFormSchema,
type ContactFormInput,
} from "@/lib/schemas/contact";
import { validateInput } from "@/lib/validation";
const contactResponseSchema = z.object({
id: z.string().min(1),
status: z.enum(["queued"]),
});
async function saveContact(input: ContactFormInput) {
// ここは実際のDB insertに置き換える
return {
id: `contact_${Date.now()}`,
status: "queued" as const,
email: input.email,
};
}
export async function POST(request: Request) {
const body: unknown = await request.json();
const validated = validateInput(contactFormSchema, body);
if (!validated.ok) {
return NextResponse.json(
{ message: "入力内容を確認してください", errors: validated.errors },
{ status: validated.status },
);
}
const saved = await saveContact(validated.data);
const response = contactResponseSchema.parse(saved);
return NextResponse.json(response, { status: 201 });
}
Next.jsのApp Routerでは route.ts から GET や POST をexportします。公式ドキュメントの通り、bodyは request.json() で読める。ここでうっかり as ContactFormInput と型アサーションを付けると、Zodを入れた意味がほぼ消えます。unknown として受けて safeParse で型を確定させる、これが基本形です。
Webhookは順番が命です。先に署名を検証して、それからpayloadスキーマを通す。署名検証より前にpayloadの中身を信用すると、攻撃者が作ったJSONをそのまま業務ロジックへ流すことになります。Claude Codeには「署名検証とpayload検証を別関数に分けて」と頼むと、この順序を崩しにくくなります。
環境変数は起動した瞬間にparseする
環境変数はすべて文字列か undefined です。DATABASE_URL が空のままでも、process.env.DATABASE_URL を直接使うとエラーが出るのはずっと後、しかもDBに繋ぎにいった瞬間。原因が遠くて探すのに苦労します。起動時に parse して、ダメなら即止めるほうが何倍も早く気づけます。
// src/env.ts
import { z } from "zod";
const envSchema = z.object({
NODE_ENV: z
.enum(["development", "test", "production"])
.default("development"),
DATABASE_URL: z.string().url("DATABASE_URL must be a valid URL"),
NEXT_PUBLIC_APP_URL: z.string().url("NEXT_PUBLIC_APP_URL must be a valid URL"),
WEBHOOK_SECRET: z.string().min(32, "WEBHOOK_SECRET must be at least 32 chars"),
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
});
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
console.error(
"Invalid environment variables",
parsed.error.flatten().fieldErrors,
);
throw new Error("Invalid environment variables");
}
export const env = parsed.data;
PORT で使っている z.coerce.number() は文字列を数値に変えてくれて便利ですが、便利すぎて危ない。"001" や " 3000 " を許したいのか、空文字をどう扱うのかを決めてから使わないと、想定外の値がするっと通ります。だから僕は、入力元が文字列だと確定している環境変数やURLクエリに限って使うようにしています。
react-hook-formと同じスキーマでつなぐ
フロントエンドでは、Zodをreact-hook-formのresolverとして渡すと、送信前にサーバーと同じルールで検証できます。ここで大事なのは、クライアント検証を入れてもサーバー検証は省略しないこと。クライアント検証はユーザー体験のため、サーバー検証は安全性のためで、役割が別物です。
// src/components/contact-form.tsx
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import {
contactFormSchema,
type ContactFormInput,
} from "@/lib/schemas/contact";
export function ContactForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<ContactFormInput>({
resolver: zodResolver(contactFormSchema),
defaultValues: {
name: "",
email: "",
plan: "trial",
message: "",
agreedToPolicy: false,
},
});
async function onSubmit(values: ContactFormInput) {
const response = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(values),
});
if (!response.ok) {
throw new Error("Failed to send contact request");
}
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("name")} aria-invalid={Boolean(errors.name)} />
{errors.name && <p>{errors.name.message}</p>}
<input {...register("email")} aria-invalid={Boolean(errors.email)} />
{errors.email && <p>{errors.email.message}</p>}
<select {...register("plan")}>
<option value="trial">Trial</option>
<option value="team">Team</option>
<option value="enterprise">Enterprise</option>
</select>
<textarea {...register("message")} />
{errors.message && <p>{errors.message.message}</p>}
<label>
<input type="checkbox" {...register("agreedToPolicy")} />
プライバシーポリシーに同意します
</label>
{errors.agreedToPolicy && <p>{errors.agreedToPolicy.message}</p>}
<button type="submit" disabled={isSubmitting}>
送信
</button>
</form>
);
}
フォームでハマりやすいのは、画面の都合でスキーマを汚すことです。表示確認用の confirmPassword やチェックボックス専用の一時値を、DB保存用スキーマにそのまま混ぜると、後で必ず困ります。Claude Codeには「フォームスキーマ」「APIスキーマ」「DB保存用スキーマを必要に応じて分ける」と最初に明示しておくと、この汚染を防げます。
Claude Codeにレビューさせる依頼文
実装が終わったら、Claude Code自身にスキーマの抜けをレビューさせます。「レビューして」だけだと表面的な指摘で終わるので、境界と失敗時の挙動を具体で指定する。この依頼文をそのまま貼ってください。
Review only the Zod validation design in these files.
Check:
1. Every external input is treated as unknown until safeParse or parse succeeds.
2. Route Handlers return 400 with field-level errors for invalid requests.
3. Environment variables fail fast at startup.
4. coerce and transform are used only where the input source is clear.
5. Form schemas, API schemas, and database insert schemas are not over-shared.
6. Error messages are user-facing and ready for localization.
Do not refactor unrelated business logic.
Return findings with file paths, risk level, and a minimal patch suggestion.
これはClaude Codeを「コード生成器」ではなく「品質監査役」として使うための指示です。Zodは小さなミスが見た目で分かりにくい——max の付け忘れ一個で長文が通ってしまう——ので、coerce・transform・safeParse・ローカライズを明示的にチェック項目へ入れると、人間の目より拾ってくれます。
テストでスキーマの「契約」を固定する
Zodのスキーマは、もう仕様書そのものです。仕様が変わったときに気づけるよう、最低限の成功例と失敗例をテストに落とします。
// src/lib/schemas/contact.test.ts
import { describe, expect, it } from "vitest";
import { contactFormSchema } from "./contact";
describe("contactFormSchema", () => {
it("正常な問い合わせを受け付ける", () => {
const result = contactFormSchema.safeParse({
name: "Masa",
email: "[email protected]",
plan: "team",
message: "チームにClaude Codeを導入したいです。",
agreedToPolicy: true,
});
expect(result.success).toBe(true);
});
it("不正なメールと短すぎる本文を弾く", () => {
const result = contactFormSchema.safeParse({
name: "Masa",
email: "not-an-email",
plan: "team",
message: "short",
agreedToPolicy: true,
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.map((issue) => issue.path.join("."))).toEqual(
expect.arrayContaining(["email", "message"]),
);
}
});
});
Claude Codeにテストを書かせるときは「正常系を1つ、異常系を3つ」と数を指定すると実用的です。フォーム・API・Webhook・環境変数の各入口で、最低一つは失敗例を作る。DB保存前検証では、アプリ内で生成した値もスキーマに通しておくと、将来の変換ミスをテストが先に教えてくれます。
よくあるハマりどころ(僕が踏んだ順)
一つ目は、TypeScript型だけで満足して実行時検証をしないこと。冒頭の僕です。type User = { email: string } はコンパイル時の約束でしかなく、外から来たJSONが本当にその形かは保証しません。request.json() as User は検証ではなく、「信じます」という宣言なだけです。
二つ目は、parse と safeParse の使い分けが曖昧なこと。ユーザー入力に parse を使って例外を上まで飛ばすと500になりやすい。フォームやAPIは safeParse で400を返し、環境変数や初期化設定は parse(または safeParse 後のthrow)で起動を止める、と決め打ちすると迷いません。
三つ目は、coerce の使いすぎ。z.coerce.number() は文字列を数値に変えますが、入力元が曖昧なまま使うと想定外の値も通します。URLクエリや環境変数のように「文字列で来る」と分かっている場所に限定する。
四つ目は、transform に副作用を入れること。transform はあくまでデータ変換用です。DB保存・メール送信・ログ送信みたいな副作用を入れると、検証のはずが業務処理を実行してしまう。副作用はZodの外に出します。
五つ目は、エラー文言とローカライズを後回しにすること。日本語メッセージをスキーマへ直書きすると最初は分かりやすいけれど、多言語化のときに翻訳管理で詰みます。最初から messageKey を返す設計にするか、ZodのエラーをAPI層で整形すると移行が楽です。
六つ目は、スキーマの再利用しすぎ。フォーム用・API用・DB挿入用・DB取得後用で必要な項目は違います。共通化は emailSchema や idSchema のような小さな部品に留めると、変更の影響範囲を最小に抑えられます。safeParse まわりの型の使い分けはTypeScript実践Tipsも参考になります。
よくある質問
Q. ZodとTypeScriptの型、両方書くのは二重管理では?
逆です。z.infer を使えばスキーマ一個から型が自動で出るので、書くのは一回。検証ルールと型が常に一致するので、むしろ二重管理がなくなります。
Q. parse と safeParse、結局どっちを使えばいい?
失敗したときの希望で決めます。400を返してユーザーに直してもらいたいフォームやAPIは safeParse。設定不備で起動ごと止めたい環境変数は parse。「例外を投げてほしいか」で選ぶと迷いません。
Q. クライアントで検証したらサーバー検証は省いていい? ダメです。クライアント検証はあくまで体験向上のためで、リクエストはツールで簡単に偽造できます。サーバー側のZod検証が最後の砦なので、ここは絶対に省略しません。
Q. Zod v3とv4でこの記事のコードは動く?
本記事は2026-06-06時点のZod公式ドキュメント(v4系)を前提にしています。z.object・safeParse・z.infer の基本APIはv3でもほぼ同じですが、エラー構造の細部が違うので、Claude Codeへ依頼するときは「Zod v4前提」と明記すると齟齬が減ります。
Q. Claude Codeにスキーマを作らせると余計なフィールドが増える。どう防ぐ?
依頼文に「使う境界」と「含めない項目」を書きます。たとえば「フォーム入力用なので id と createdAt は含めない」。本文の表をそのまま渡すと、入口ごとに必要な項目だけへ絞ってくれます。
実際に試した結果
as User で本番を落としてから、僕のなかでルールが一本に決まりました。外から来た値は全部 unknown として受け、Zodを通るまで一切信用しない。これだけで「なぜか文字列が混ざる」系のバグがぱたっと止まりました。
そしてスキーマ書きは、もうほぼClaude Codeに任せています。コツは一つで、項目・境界・含めない項目を表で渡すこと。曖昧に頼んだときは余計なフィールドを足されて手戻りしましたが、表を渡すようにしてからは一発で通るようになりました。掲載コードは zod・react-hook-form・@hookform/resolvers・vitest が入っている前提のTypeScriptで、実案件ではここに認証・CSRF対策・Webhook署名検証・DB制約のテストを足してから公開しています。公式の挙動はZod公式ドキュメントで都度確認してください。
既存コードへどう入れるか、エラーをどう画面に返すか、チーム用の実装ルールをどう固定するか——このあたりで詰まったら、Claude Code Labの相談・トレーニングで、あなたのコードを見ながらClaude Codeへの依頼文ごと整理します。スキーマを作るより、毎回同じ品質でレビューさせる仕組みを作るほうが、長い目で効きます。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
制作会社がClaude Codeに触らせる前に決める権限チェックリスト
クライアントサイトを壊さずにAI編集を使うための、制作会社向け権限と確認の型です。
SaaSサポートのバグ報告をClaude Codeで再現手順に変える実務フロー
問い合わせ文をそのまま開発へ投げず、再現手順、証拠、次の一手に整えるサポート向け手順です。
Obsidianの古いメモをClaude Codeの指示書に変える10分ルーチン
Obsidianに溜めたメモが毎回ゴミになる人へ。事実・決定・未確認に仕分けして、Claude Codeがそのまま動ける指示書に変える朝の10分の型を紹介します。