Use Cases (Updated: 6/2/2026)

Claude Code Zod Validation Guide for TypeScript Apps

Build safer forms, APIs, env checks, webhooks, and tests with Claude Code and Zod in TypeScript projects.

Claude Code Zod Validation Guide for TypeScript Apps

Why Zod Belongs at Runtime Boundaries

TypeScript protects you while you write code, but it does not inspect data that arrives while the app is running. A browser form, a fetch request, a webhook, process.env, or a value about to be inserted into a database can still be malformed. Runtime validation means checking those values during execution, before business logic trusts them. Zod gives you a schema, which is a small contract for the shape of the data, and it can infer the matching TypeScript type from that contract.

Claude Code is useful here because validation work is repetitive but easy to get subtly wrong. It can draft schemas, wire them into route handlers, add tests, and review whether external inputs are treated as unknown until Zod accepts them. The quality depends on the prompt. Ask for “a Zod schema” and you get a schema. Ask for “form validation, API request/response validation, environment variable validation, webhook payload validation, and DB insert validation with tests” and you get a usable implementation plan.

Use the official references when you check details: Zod documentation for schema behavior and Next.js Route Handler documentation for App Router request handlers. This article focuses on the practical pattern Claude Code should implement and review.

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

The important part is the first arrow. Do not cast external data into a TypeScript type. Validate it, then use the inferred type after validation succeeds.

Use Cases to Give Claude Code

Before asking Claude Code to write code, list the boundaries. Different boundaries need different strictness.

Use caseEntry pointWhat Zod should protect
Form validationBrowser inputEmpty strings, email format, length, consent flags
API request/responserequest.json() and returned JSONInvalid payloads, extra states, response contracts
Environment variablesprocess.envMissing secrets, malformed URLs, invalid ports
Webhook payloadsThird-party POST requestsEvent type, object IDs, amounts, signature flow
Before DB insertValues after app-side mappingPersistable shape, enums, required IDs

Keep these schemas close to their boundary. A form schema often has checkbox values and confirmation fields. A database insert schema should not include UI-only fields. Share small pieces such as emailSchema, uuidSchema, or moneySchema, but avoid one giant schema used everywhere. For deeper form integration, see the internal React Hook Form guide. For API-wide type safety, compare the pattern with tRPC development.

Start with a Form Schema

The following schema is intentionally small enough to copy. It validates a contact form and exports the inferred TypeScript type. Ask Claude Code to keep error messages user-facing and to avoid adding database fields such as id or createdAt to a form schema.

// src/lib/schemas/contact.ts
import { z } from "zod";

export const contactFormSchema = z.object({
  name: z
    .string()
    .trim()
    .min(1, "Please enter your name")
    .max(80, "Name must be 80 characters or fewer"),
  email: z
    .string()
    .trim()
    .email("Please enter a valid email address"),
  plan: z.enum(["trial", "team", "enterprise"]),
  message: z
    .string()
    .trim()
    .min(10, "Message must be at least 10 characters")
    .max(2000, "Message must be 2000 characters or fewer"),
  agreedToPolicy: z
    .boolean()
    .refine((value) => value, "Privacy policy agreement is required"),
});

export type ContactFormInput = z.infer<typeof contactFormSchema>;

trim() matters because a string containing only spaces should not pass a required field. z.enum matters because it narrows a string to known choices before your app uses it. z.infer matters because it removes the need to maintain a separate ContactFormInput interface by hand.

Convert safeParse Results into API Errors

Use parse when failure should stop execution, for example during environment setup. Use safeParse when failure should become a controlled response, for example a form error or a 400 Bad Request. This helper returns either typed data or field-level validation problems.

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

This is a good place to standardize localization later. Today it returns messages. In a larger app, return message keys and let the UI choose English, Japanese, Spanish, or another locale.

Validate Next.js Requests and Responses

A route handler should validate input before business logic and validate output before returning. Response validation sounds strict, but it catches accidental contract changes early. The example below uses unknown for the JSON body, safeParse for the request, and parse for the internal response contract.

// 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: "Please check your input", errors: validated.errors },
      { status: validated.status },
    );
  }

  const saved = await saveContact(validated.data);
  const response = contactResponseSchema.parse(saved);

  return NextResponse.json(response, { status: 201 });
}

For webhooks, add signature verification before payload validation. Claude Code should not inspect a payload and trigger business logic until the signature is checked. Prompt it to separate verifySignature, webhookPayloadSchema, and handleWebhookEvent so the security step is visible in review.

Validate Environment Variables at Startup

Environment variables are strings or undefined. If DATABASE_URL is missing, you want the app to fail at startup, not later when the first request hits the database.

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

Use coerce only when the source is clearly string-based, such as a URL query or an environment variable. Overusing coercion can turn suspicious input into acceptable input without anyone noticing.

Connect Zod to react-hook-form

Client validation improves the user experience, but server validation remains mandatory. The browser can be bypassed. The route handler above is still the source of safety.

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

When Claude Code edits this file, tell it not to remove the server-side schema check. A common regression is to make the form look correct while quietly weakening the API boundary.

Claude Code Review Prompt

Use Claude Code as a reviewer after implementation. The prompt should be narrow and testable.

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.

This prompt catches the mistakes that matter: trusting TypeScript types at runtime, using parse where a controlled 400 is needed, coercing too much, hiding side effects inside transform, and reusing schemas beyond their real boundary.

Add Contract Tests

Treat schemas as product contracts. Test one valid payload and several invalid payloads. This makes future edits by humans or Claude Code safer.

// 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"]),
      );
    }
  });
});

For DB insert validation, add a test that uses the mapped object right before persistence. That catches cases where the app changes a field name or enum value after the form already passed.

Pitfalls to Avoid

First, TypeScript types alone are not runtime validation. request.json() as ContactFormInput is a promise you make to the compiler, not proof about the payload.

Second, use parse and safeParse intentionally. User input and API requests usually need safeParse so the app can return a 400. Startup configuration can use parse or throw after safeParse, because a bad config should stop the process.

Third, do not overuse coerce. It is useful for query strings and environment variables, but dangerous when it hides dirty input. Decide how to handle empty strings, whitespace, and numeric-looking strings before enabling it.

Fourth, keep transform pure. It should normalize data, not write to the database, send email, or call analytics. Side effects belong after validation.

Fifth, plan error messages and localization early. A single English string in the schema is fine for a prototype. A multilingual product may need message keys or an API layer that maps Zod issues into localized copy.

Sixth, avoid schema reuse for its own sake. Form, API, webhook, and DB schemas often overlap, but they are not the same contract. Reuse small building blocks, not the whole object blindly.

Consultation and Verification Note

Claude Code Lab consulting and training can help when validation has spread across many files, when webhooks need safer handling, or when a team needs repeatable review prompts. The English Claude Code training page is the best next step for project-specific guidance.

The examples in this article were reviewed on 2026-06-02 against the current Zod docs and Next.js Route Handler docs. They assume a TypeScript project with zod, react-hook-form, @hookform/resolvers, and vitest installed. In production work, I also add authentication checks, CSRF or signature verification where relevant, database constraints, and failing tests for each external boundary.

#Claude Code #Zod #validation #TypeScript #type safety
Free

Free PDF: Claude Code Cheatsheet

Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.

We handle your data with care and never send spam.

Level up your Claude Code workflow

Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.

Masa

About the Author

Masa

Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.