Use Cases (更新: 2026/6/2)

Claude CodeでZodバリデーションを実装する実践ガイド

Claude CodeとZodでフォーム、API、環境変数、Webhookを安全に検証する実装手順を初心者向けに解説します。

Claude CodeでZodバリデーションを実装する実践ガイド

ZodをClaude Codeに任せる前に決めること

TypeScriptの型は、エディタ上では強力です。しかし、ブラウザのフォーム、外部API、Webhook、環境変数、データベース保存前の入力は、実行時にはただの不明な値です。ここで必要になるのがruntime validation、つまり「実行中に値の形を確かめる検証」です。Zodは、スキーマという入力データの設計図を書き、その設計図からTypeScript型も取り出せるライブラリです。

Claude CodeはZodと相性が良いです。理由は単純で、バリデーションは「項目」「型」「制約」「エラーメッセージ」「使う場所」を丁寧に列挙する作業だからです。曖昧に「いい感じにバリデーションして」ではなく、「フォーム送信前、Next.js Route Handler、環境変数、Webhook payload、DB保存前で使う」と境界を指定すると、Claude Codeは再利用しやすいスキーマとテストまでまとめて提案できます。

この記事では初心者向けに、Claude Codeへ依頼する前の整理、コピーできるZod実装、parsesafeParseの使い分け、Next.jsでのAPI request/response検証、react-hook-form連携、環境変数検証、テストまでを一つの流れで扱います。公式仕様はZod公式ドキュメントNext.js Route Handlers公式ドキュメントも確認してください。

まず全体像は次のように考えます。

unknown input
  -> Zod schema
  -> safeParse
  -> typed data
  -> business logic
  -> response schema
  -> client

この図で大事なのは、業務ロジックの前に必ずunknownを検証済みデータへ変換することです。TypeScript型を先に信じるのではなく、Zodで境界を固めてから型を使います。

ユースケース別の設計方針

Zodスキーマは一つ作って終わりではありません。入力元によって、厳しくすべき場所と許容すべき場所が変わります。

ユースケース入口Zodで守ること
フォームブラウザからの入力空文字、メール形式、文字数、同意チェック
API request/responserequest.json()と返却値不正なJSON、過剰なプロパティ、レスポンス契約
環境変数process.env起動時の設定漏れ、URL形式、ポート番号
Webhook payload外部サービスからのPOST署名検証後のイベント種別、必須ID、金額
DB入力前検証保存直前の値アプリ内変換後も保存できる形か

Claude Codeへは、この表をそのまま渡すのが有効です。「フォームとAPIで同じスキーマを完全共有して」と依頼すると、画面固有の項目とサーバー固有の項目が混ざりやすくなります。共有するのはメール形式やID形式などの小さな部品にして、入口ごとのスキーマは分けるほうが保守しやすいです。

関連するフォーム実装はReact Hook Form実践ガイドも参考になります。API全体を型安全に組みたい場合はtRPC型安全API開発も併せて読むと、Zodの置き場所が見えやすくなります。

基本のZodスキーマを作る

最初のスキーマは、問い合わせフォームを例にします。ここでは「スキーマ」は入力値のルール表、「z.infer」はスキーマからTypeScript型を取り出す仕組みです。Claude Codeに作らせる場合も、項目名、必須/任意、文字数、選択肢、エラーメッセージを明示します。

// 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>;

ポイントは、namemessagetrim()を使い、空白だけの入力を通さないことです。一方で、planのような選択肢はz.enumにします。stringで受けて後から比較するより、受け取った瞬間に許可値だけへ絞り込むほうが安全です。

Claude Codeへ依頼するときは「Zod v4前提」「エラーメッセージは日本語」「フォーム入力用なのでDBのidcreatedAtは含めない」と書くと、余計なフィールドを混ぜられにくくなります。

safeParseで失敗をHTTPエラーに変換する

parseは失敗時に例外を投げます。設定ファイルや環境変数のように「失敗したら起動を止めたい」場面に向いています。safeParseは成功/失敗をオブジェクトで返します。フォームやAPIのように「400 Bad Requestとして返したい」場面ではsafeParseが扱いやすいです。

次のヘルパーは、Zodの失敗を画面やAPIで使いやすい配列に整形します。

// 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) {
  // Replace this with your database 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.tsGETPOSTをexportします。公式ドキュメントにもある通り、Requestのbodyはrequest.json()で読めます。ここでas ContactFormInputと型アサーションを付けてしまうと、Zodを入れる意味が薄れます。unknownとして受け、safeParseで型を確定させるのが基本です。

Webhookでは、先に署名を検証し、その後にpayloadスキーマを通します。署名検証前にpayloadの中身を信用すると、攻撃者が作ったJSONを業務ロジックへ渡すことになります。Claude Codeには「署名検証とpayload検証を別関数に分けて」と依頼してください。

環境変数は起動時にparseする

環境変数はすべて文字列かundefinedです。DATABASE_URLが空でも、process.env.DATABASE_URLを直接使うとエラーは遅れて出ます。起動時に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;

z.coerce.number()は便利ですが、使いすぎると危険です。"001"" 3000 "のような値を許したいのか、空文字をどう扱うのかを決めてから使います。環境変数のPORTのように入力元が文字列であることが明らかな場合に限定すると、意図しない変換を避けやすくなります。

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")} />
        I agree to the privacy policy
      </label>
      {errors.agreedToPolicy && <p>{errors.agreedToPolicy.message}</p>}

      <button type="submit" disabled={isSubmitting}>
        Send
      </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は小さなミスが見た目では分かりにくいので、レビュー指示にcoercetransformsafeParse、ローカライズを含めると効果があります。

テストでスキーマの契約を固定する

Zodスキーマは仕様そのものです。仕様が変わったときに気づけるよう、最低限の成功例と失敗例をテストにします。

// src/lib/schemas/contact.test.ts
import { describe, expect, it } from "vitest";
import { contactFormSchema } from "./contact";

describe("contactFormSchema", () => {
  it("accepts a valid contact request", () => {
    const result = contactFormSchema.safeParse({
      name: "Masa",
      email: "masa@example.com",
      plan: "team",
      message: "I want to introduce Claude Code to my team.",
      agreedToPolicy: true,
    });

    expect(result.success).toBe(true);
  });

  it("rejects invalid email and short message", () => {
    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型だけでruntime検証をしないことです。type User = { email: string }はコンパイル時の約束であり、外から届いたJSONが本当にその形かは保証しません。request.json() as Userは、バリデーションではなく「信じる」と宣言しているだけです。

二つ目は、parsesafeParseの使い分けを曖昧にすることです。ユーザー入力にparseを使って例外を上位まで飛ばすと、500エラーになりやすいです。フォームやAPIではsafeParseで400を返し、環境変数や初期化設定ではparseまたはsafeParse後のthrowで起動を止めます。

三つ目は、coerceを過剰に使うことです。z.coerce.number()は文字列を数値に変換しますが、入力元が曖昧なまま使うと、想定外の値も通しやすくなります。URL queryや環境変数のように「文字列で来る」と分かっている場所へ限定しましょう。

四つ目は、transformに副作用を入れることです。transformはデータ変換のためのものです。DB保存、メール送信、ログ送信のような副作用を入れると、検証のはずが業務処理を実行してしまいます。副作用はZodの外に出します。

五つ目は、エラー文言とローカライズを後回しにすることです。スキーマに日本語メッセージを直接書くと分かりやすい一方、多言語化では翻訳管理が必要になります。最初からmessageKeyを返す設計にするか、ZodのエラーをAPIで整形する層を作ると移行しやすいです。

六つ目は、スキーマを再利用しすぎることです。フォーム用、API用、DB挿入用、DB取得後用では必要な項目が違います。共通化はemailSchemaidSchemaのような小さな部品に留めると、変更の影響範囲を抑えられます。

Claude Code Labでの相談に向くケース

Zodは小さく始められますが、実務では「どの境界で検証するか」「既存APIへどう入れるか」「エラーをどう画面へ返すか」で悩みが出ます。Claude Code Labの相談・トレーニングでは、既存コードを見ながら、Claude Codeに渡すレビュー指示、スキーマ分割、テスト観点、チーム用の実装ルールまで整理できます。

特に、フォームが増えてきたSaaS、Webhookを受ける決済・予約システム、環境変数が多いNext.jsアプリ、DB保存前の検証が散らばっている管理画面では、Zod導入の効果が出やすいです。単にスキーマを作るだけでなく、Claude Codeが毎回同じ品質でレビューできるプロンプトを作ることが重要です。

まとめと検証メモ

Claude CodeとZodを組み合わせると、フォーム、API request/response、環境変数、Webhook payload、DB入力前検証を一貫した型安全ルールで扱えます。初心者が最初に意識するべきことは、外部入力をunknownとして受けること、safeParseで失敗を扱うこと、入口ごとにスキーマを分けることです。

この記事のコード例は、2026-06-02時点のZod公式ドキュメントとNext.js Route Handler公式ドキュメントを前提に、Claude Codeでレビューしやすい形に整理しました。掲載コードはプロジェクトにzodreact-hook-form@hookform/resolversvitestが入っている前提のTypeScript例です。実案件では、ここに認証、CSRF対策、Webhook署名検証、DB制約のテストを追加してから公開しています。

#Claude Code #Zod #バリデーション #TypeScript #型安全
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。