Skip to content

TypeScript Form Validation - Zod and React Hook Form

Practical guide: validate React forms with Zod 3.x and TypeScript. One schema gives you runtime checks and compile-time types, no duplication, no drift.

· · 7 min read
A TypeScript code editor showing a Zod schema definition for form validation with type inference

Quick Take

Learn how to validate React forms with Zod 3.x and TypeScript. One schema gives you runtime checks and compile-time types, no duplication, no drift.

Before going further, make sure your project has TypeScript strict mode turned on. Schema-first validation pays off the most when the compiler refuses to let inferred types drift back into any.

The old pattern was exhausting. You'd write a TypeScript interface for your form data, then write separate validation logic to check the same constraints at runtime. Two definitions, same rules. The moment requirements changed, say, password minimum length goes from 6 to 10, you'd update one and forget the other.

Zod 3.x fixes this. You define a schema once. TypeScript types come from that schema. Runtime validation runs from that schema. There's one place to update, and nothing can drift.

TL;DR:

  • Zod 3.x lets you define a schema once and infer TypeScript types with z.infer<typeof schema>, no separate interfaces (Zod Documentation)
  • React Hook Form 7.x integrates via zodResolver from @hookform/resolvers for zero-config validation wiring (React Hook Form Documentation)
  • Schemas run on the server too, one definition covers your API route and your form component
  • Cross-field validation (like password confirmation) uses .refine() on the full object schema

Why Zod Changes Form Validation

Zod makes the schema the single source of truth for both runtime behavior and static types. According to the Zod Documentation, the library was downloaded over 24 million times per week on npm in 2025, reflecting how broadly the TypeScript community has adopted schema-first validation over hand-written type guards.

The core insight is simple: TypeScript types vanish at runtime. You can annotate a function parameter as string, but if something passes a number at runtime, TypeScript can't help you. Zod validates the actual values, then gives you the type.

import { z } from 'zod';

const emailSchema = z.string().email('Invalid email address');

// Runtime validation
const result = emailSchema.safeParse('not-an-email');
// result.success === false, result.error has the message

// Compile-time type
type Email = z.infer<typeof emailSchema>;
// Email is `string`

That's it. One definition. The type Email stays in sync with the validation rule automatically. Change .email() to .url() and the type updates without touching anything else.

Duplicating a schema and a matching interface side by side is one of the worst TypeScript code smells, the two will silently drift the next time someone edits one without the other.

Maintaining parallel type definitions and validation functions is one of the worst TypeScript code smells I run into on client projects. It's always the validation that falls behind, and it's always caught in production.

A close-up of a sign-in form with Google and Apple options and an email field
Photo by Zulfugar Karimov on Unsplash

Building a Real Registration Form

I've used this exact pattern in production apps, a registration form is the clearest way to see why schema-first validation pays off. Here's the full setup. Forms in dashboards and admin UIs carry additional design decisions beyond validation logic: inline error placement, field grouping, and error state styling all affect whether users can recover from mistakes quickly. The dashboard design patterns guide on Art of Styleframe covers how to structure those visual patterns so validation feedback lands in the right place within the layout.

Step 1: Install dependencies.

npm install zod react-hook-form @hookform/resolvers

Step 2: Define the schema.

import { z } from 'zod';

const registerSchema = z.object({
  name: z.string().min(2, 'Name must be at least 2 characters'),
  email: z.string().email('Invalid email address'),
  password: z.string().min(8, 'Password must be at least 8 characters'),
  confirmPassword: z.string(),
}).refine(data => data.password === data.confirmPassword, {
  message: "Passwords don't match",
  path: ['confirmPassword'],
});

type RegisterForm = z.infer<typeof registerSchema>;

RegisterForm is now { name: string; email: string; password: string; confirmPassword: string }. TypeScript inferred that from the schema. No separate interface.

Step 3: Wire up React Hook Form.

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';

export function RegistrationForm() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<RegisterForm>({
    resolver: zodResolver(registerSchema),
  });

  const onSubmit = (data: RegisterForm) => {
    // data is fully typed, no casting needed
    console.log(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <input {...register('name')} placeholder="Full name" />
        {errors.name && <p>{errors.name.message}</p>}
      </div>
      <div>
        <input {...register('email')} placeholder="Email" type="email" />
        {errors.email && <p>{errors.email.message}</p>}
      </div>
      <div>
        <input {...register('password')} placeholder="Password" type="password" />
        {errors.password && <p>{errors.password.message}</p>}
      </div>
      <div>
        <input {...register('confirmPassword')} placeholder="Confirm password" type="password" />
        {errors.confirmPassword && <p>{errors.confirmPassword.message}</p>}
      </div>
      <button type="submit">Create account</button>
    </form>
  );
}

React Hook Form calls zodResolver on submit. If validation fails, errors land in formState.errors with the messages you defined in the schema. The onSubmit callback only runs on valid data, and data there is typed as RegisterForm without any assertion.

Beyond Simple Fields

Transforming Input Before Validation

Zod can preprocess values before applying rules. This is useful for inputs you don't fully control, whitespace in names, string-coerced numbers from URL params.

const userSchema = z.object({
  name: z.string().trim().min(2, 'Name too short'),
  age: z.coerce.number().min(18, 'Must be 18 or older'),
});

.trim() runs before .min(2), so a name of " a " fails correctly. z.coerce.number() converts the string "25" to the number 25, handy for form inputs, which always return strings.

Async Validation

Zod supports async refinements for things like username availability:

const usernameSchema = z.object({
  username: z.string().min(3).refine(
    async (val) => {
      const taken = await checkUsernameAvailability(val);
      return !taken;
    },
    { message: 'Username already taken' }
  ),
});

React Hook Form handles async resolvers, pass mode: 'onBlur' to useForm to trigger async checks when the user leaves the field rather than on every keystroke.

Reusing the Schema on the Server

This is the part most tutorials skip, and it's the biggest productivity win. The same registerSchema works identically in a Next.js API route:

// pages/api/register.ts (or app/api/register/route.ts)
import { registerSchema } from '@/schemas/register';

export async function POST(req: Request) {
  const body = await req.json();
  const result = registerSchema.safeParse(body);

  if (!result.success) {
    return Response.json({ errors: result.error.flatten() }, { status: 400 });
  }

  // result.data is fully typed here too
  await createUser(result.data);
  return Response.json({ ok: true });
}

Client validation gives fast feedback. Server validation is the real check, it's what actually protects your database. Same schema handles both. You can use the same pattern for API data fetched on the client too.

A hand holding a phone that displays a large green validation checkmark
Photo by Franck on Unsplash

Common Zod Patterns Worth Knowing

Optional Fields with Defaults

const settingsSchema = z.object({
  theme: z.string().default('light'),
  notifications: z.boolean().optional(),
  pageSize: z.number().default(20),
});

.default() means the field can be absent from input, Zod fills it in. .optional() means it can be absent and the type reflects that (boolean | undefined).

Enum Types for Select Fields

const roleSchema = z.enum(['admin', 'editor', 'viewer']);
type Role = z.infer<typeof roleSchema>;
// Role is "admin" | "editor" | "viewer"

Much cleaner than manually writing a union type and a separate runtime check. Change the enum values and the type updates automatically.

Nested Object Schemas for Address Forms

const addressSchema = z.object({
  street: z.string().min(1),
  city: z.string().min(1),
  postCode: z.string().regex(/^\d{5}$/, 'Invalid post code'),
});

const checkoutSchema = z.object({
  email: z.string().email(),
  shippingAddress: addressSchema,
  billingAddress: addressSchema.optional(),
});

Compose schemas like objects. addressSchema can be reused across checkoutSchema, profileSchema, whatever needs it.

superRefine for Complex Cross-Field Rules

.refine() handles one condition. .superRefine() handles multiple conditions with different error messages:

const dateRangeSchema = z.object({
  startDate: z.string(),
  endDate: z.string(),
}).superRefine((data, ctx) => {
  if (data.endDate < data.startDate) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: 'End date must be after start date',
      path: ['endDate'],
    });
  }
  if (data.startDate === data.endDate) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: 'Start and end date cannot be the same',
      path: ['startDate'],
    });
  }
});

I reach for superRefine whenever a single .refine() isn't enough, conditional required fields, date range checks, anything where the error location matters. It's verbose, but it's clear.


Schema-first validation is an opinion worth holding firmly: one schema in one place, shared between the form component and the API route. When the product team asks for a new required field or a stricter email rule, you update one file. The TypeScript type updates. The client validation updates. The server validation updates. That's the deal Zod offers, and it's a good one.

Frequently Asked Questions

Does Zod replace TypeScript types for form data?
Zod doesn't replace TypeScript, it generates TypeScript types for you. Use z.infer<typeof yourSchema> to extract a fully typed interface directly from the schema. Change the schema, and the type updates automatically. You never write a separate interface for form data again. (Zod Documentation)
Is Zod compatible with React Hook Form?
Yes. React Hook Form 7.x ships a @hookform/resolvers package that includes a zodResolver adapter. Pass zodResolver(yourSchema) as the resolver option when calling useForm, and React Hook Form delegates all validation to Zod. Error messages come straight from your schema definitions. (React Hook Form Documentation)
Can I reuse the same Zod schema on the server?
That's the best part. Zod runs in Node.js, Deno, and edge runtimes without modification. Define one schema in a shared file, import it in your React component and your Next.js API route, and both ends validate against identical rules. No drift between client and server validation is possible.
How do I validate fields that depend on each other in Zod?
Use the .refine() or .superRefine() method on the whole object schema, not on individual fields. This lets you compare any two fields, password confirmation, date range checks, conditional required fields. The path option in .refine() tells Zod which field to attach the error message to.