Scientyfic World

Form Validation in TypeScript Projects Using Zod and React Hook Form

If you’ve ever built a form-heavy React app, you know how quickly validation logic can get messy. Managing state, checking field types, and showing user-friendly error messages often leads to...

Share:

Get an AI summary of this article

Form Validation in TypeScript with Zod and React Hook Form

If you’ve ever built a form-heavy React app, you know how quickly validation logic can get messy. Managing state, checking field types, and showing user-friendly error messages often leads to spaghetti code — especially in large TypeScript projects. That’s where Zod and React Hook Form come in. Together, they offer type-safe, declarative, and maintainable form validation with minimal boilerplate.

In this guide, I’ll walk you through how to implement complete form validation using Zod and React Hook Form — from installation to advanced type-safe schemas.

What is Zod and Why Should You Use It?

Zod is a TypeScript-first schema validation library. You define your data structure once, and Zod automatically infers the TypeScript types. That means fewer bugs and no duplication between your runtime validation and compile-time typing.

Here’s what makes Zod stand out:

  • Type inference out of the box
  • Works perfectly with React Hook Form via resolvers
  • Clean, readable syntax
  • Built-in error messaging

Example:

import { z } from 'zod';

const userSchema = z.object({
  name: z.string().min(3, 'Name must be at least 3 characters long'),
  email: z.string().email('Invalid email address'),
  age: z.number().min(18, 'You must be 18 or older')
});

// Inferred TypeScript type
type User = z.infer<typeof userSchema>;
TypeScript

Why Combine Zod with React Hook Form?

React Hook Form (RHF) manages form state efficiently, re-renders only when necessary, and integrates seamlessly with Zod. You just need a single line — zodResolver() — to connect both worlds.

Zod + RHF = effortless validation with full type safety.

Here’s the mental model:

  • React Hook Form controls the form inputs
  • Zod validates the data schema
  • The resolver bridges them together

Setting Up the Project

You’ll need a React + TypeScript setup. If you don’t have one yet:

npx create-react-app zod-form-demo --template typescript
cd zod-form-demo
npm install react-hook-form zod @hookform/resolvers
Bash

Then, open your project and let’s start coding.

Step 1: Define Your Zod Schema

Start by defining your schema for form validation.

// src/schemas/userSchema.ts
import { z } from 'zod';

export const userSchema = z.object({
  name: z.string().min(3, 'Name should be at least 3 characters long'),
  email: z.string().email('Invalid email format'),
  password: z.string().min(6, 'Password must have at least 6 characters'),
  confirmPassword: z.string()
}).refine((data) => data.password === data.confirmPassword, {
  message: 'Passwords must match',
  path: ['confirmPassword']
});

export type UserSchema = z.infer<typeof userSchema>;
TypeScript

The refine() method is powerful — it lets you apply cross-field validation (like matching passwords) while keeping type safety intact.

Step 2: Integrate with React Hook Form

Now, we’ll connect Zod to RHF using the zodResolver from @hookform/resolvers.

// src/App.tsx
import React from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { userSchema, UserSchema } from './schemas/userSchema';

export default function App() {
  const { register, handleSubmit, formState: { errors } } = useForm<UserSchema>({
    resolver: zodResolver(userSchema)
  });

  const onSubmit = (data: UserSchema) => {
    console.log('Form Submitted:', data);
  };

  return (
    <div className="container">
      <h1>Register</h1>
      <form onSubmit={handleSubmit(onSubmit)}>
        <div>
          <label>Name</label>
          <input {...register('name')} />
          {errors.name && <p>{errors.name.message}</p>}
        </div>

        <div>
          <label>Email</label>
          <input {...register('email')} />
          {errors.email && <p>{errors.email.message}</p>}
        </div>

        <div>
          <label>Password</label>
          <input type="password" {...register('password')} />
          {errors.password && <p>{errors.password.message}</p>}
        </div>

        <div>
          <label>Confirm Password</label>
          <input type="password" {...register('confirmPassword')} />
          {errors.confirmPassword && <p>{errors.confirmPassword.message}</p>}
        </div>

        <button type="submit">Sign Up</button>
      </form>
    </div>
  );
}
TypeScript

Every field automatically inherits validation from Zod — no need to manually check lengths or patterns.

Step 3: Add Type Safety Everywhere

This is where TypeScript shines. Because Zod infers the UserSchema type, your entire form and submission handler are fully type-checked. Try submitting a non-numeric age or missing field — your IDE will catch it before runtime.

You can also reuse your schema on both frontend and backend, guaranteeing consistent validation.

Step 4: Custom Validation Messages

Want more readable or localized errors? You can override Zod’s default messages:

const schema = z.object({<br>  email: z.string({ required_error: 'Email is required' }).email('Enter a valid email'),<br>  password: z.string({ required_error: 'Password cannot be empty' }).min(8, 'At least 8 characters'),<br>});
JavaScript

Step 5: Async Validation Example

You can even perform async checks — like verifying if a username already exists.

const schema = z.object({
  username: z.string().min(3),
}).superRefine(async (data, ctx) => {
  const exists = await fetch(`/api/users?username=${data.username}`).then(res => res.json());
  if (exists) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: 'Username already taken',
      path: ['username']
    });
  }
});
JavaScript

With RHF, you can call handleSubmit normally — the resolver handles async automatically.

Step 6: Improving UX (Optional)

Use watch() or trigger() from RHF to create live validation feedback:

const { register, watch, trigger } = useForm<UserSchema>({ resolver: zodResolver(userSchema) });

useEffect(() => {
  trigger('password'); // live validation
}, [watch('password')]);
JavaScript

Common Mistakes and Fixes

  • Error not showing? → Make sure to render errors.fieldName.message inside JSX.
  • Zod validation not triggering? → Check that you used zodResolver(schema).
  • Refine not working? → Add path in the error definition.

Conclusion

Zod and React Hook Form make validation elegant and type-safe. You write your validation schema once, infer types automatically, and eliminate redundant code. This setup scales beautifully — from simple login forms to complex multi-step workflows.

So next time you find yourself writing custom validation functions, try pairing Zod with React Hook Form. You’ll never look back.

FAQs

Is Zod better than Yup?

Zod is TypeScript-first, meaning it infers types directly from schemas. Yup requires you to define types separately.

Does Zod support async validation?

Yes, via superRefine() or refine() with async logic.

Can I reuse the same schema on backend?

Absolutely. Zod works in Node.js, so you can share schemas between client and server.

How does performance compare to custom validation?

React Hook Form + Zod is highly performant because only affected components re-render.

What if I want multiple schemas for one form?

You can compose schemas using z.union() or z.intersection() to handle dynamic fields.

Snehasish Konger
Developed @scientyficworld.org | Technical writer @Nected | Content Developer
Connect with Snehasish Konger

On This page

Take a Pause with Intervals

A Sunday letter on building, writing, and thinking deeper as a developer — short, honest, and worth your time.

Snehasish Konger profile photo

"Hey there — I'm Snehasish. Hope this post saved you some head-scratching time! I've spent years turning technical chaos into clarity, and I'm here to be your guide through the maze of modern tech. Stick around for more lightbulb moments — we're just getting started."

Related Posts