React Hook Form with Zod or Yup: A Schema Validation Comparison
AI generated
{ }
React · Forms · TypeScript
React Hook Form: Zod or Yup for Schema Validation?
Type inference, API design, and migration paths compared side by side

Anyone building forms in React 19 with React Hook Form will eventually need a schema validation library. Zod and Yup solve the same problem with different priorities: TypeScript-first versus an established JavaScript API. This article compares both across type inference, syntax, nested structures, and migration effort.

15 min read React Hook Form Zod Yup TypeScript Forms

1. Why Schema Validation Matters in Forms

React Hook Form manages form state and rendering but deliberately leaves the actual validation of input values to external libraries. This separation has proven itself because validation rules are often more complex than simple required-field checks: minimum lengths, formats, dependencies between fields, asynchronous checks against a backend. A schema bundles all these rules in one place, readable and reusable, instead of scattering them across validate functions throughout the form.

Zod and Yup are the two most common candidates for this task in the React ecosystem. Both ship an official resolver for React Hook Form, both describe form structure declaratively as an object schema. The difference runs deeper: Zod was designed for TypeScript from day one and derives types directly from the schema, while Yup comes from the plain JavaScript world and had typing added later. This origin still shapes the API, error behavior, and ergonomics of both libraries today.

2. Basic Setup: zodResolver and yupResolver Compared

Both resolvers follow the same pattern: they are passed to useForm via the resolver option and translate the respective schema into React Hook Form's internal error format. The rest of the form, meaning register, handleSubmit, and error display via formState.errors, remains identical either way. That makes switching from one library to the other at the form level surprisingly cheap, as long as only the schema itself is swapped out.

The difference only shows up at the schema import and the type passed to useForm. With Zod, a single type export via z.infer is enough; with Yup, a separate InferType call is usually needed. The example below shows the Zod variant because it produces more compact code; the Yup variant follows in later sections with the corresponding adjustments.


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

const loginSchema = z.object({
  email: z.string().email("Invalid email address"),
  password: z.string().min(8, "At least 8 characters"),
});

type LoginFormValues = z.infer<typeof loginSchema>;

function LoginForm() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<LoginFormValues>({
    resolver: zodResolver(loginSchema),
  });

  const onSubmit = (values: LoginFormValues) => {
    console.log(values);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("email")} />
      {errors.email && <span>{errors.email.message}</span>}
      <input type="password" {...register("password")} />
      {errors.password && <span>{errors.password.message}</span>}
      <button type="submit">Sign in</button>
    </form>
  );
}

3. Zod's Type Inference: z.infer as the Single Source of Truth

Zod's biggest practical advantage lies in z.infer. The schema is simultaneously the runtime validation and the source for the TypeScript type, and both stay automatically in sync. If a field in the schema changes, say an optional field becomes required, the TypeScript compiler flags every place still assuming the old structure. This coupling prevents an entire class of bugs where validation rules and type definitions drift apart over time.

Zod also maps more complex TypeScript constructs cleanly: unions, literals, optional and nullable fields, even recursive types via z.lazy. For a form with conditional fields, say an address form that requires different fields depending on the selected country, this can be modeled with z.discriminatedUnion, and the derived type reflects exactly that distinction, including correct type narrowing in the calling code.

4. Yup's InferType: Solid but with Limitations

Yup offers a comparable mechanism with InferType that works reasonably well for most everyday cases. For flat object schemas with string, number, and boolean, Yup produces exactly the expected type, including optional fields via .optional() or .notRequired(). For simple login or contact forms, the difference to Zod is barely noticeable in practice, since both libraries correctly type the same basic structure.

Gaps appear in more complex cases: conditional validation via .when() can be expressed cleanly at runtime in Yup, but the derived TypeScript type does not always capture that conditionality precisely, because Yup's type system was layered on top of the JavaScript API afterward instead of being type-driven from the start. In such cases, teams often resort to manual type overrides, which undermines the actual benefit of a single source of truth.

5. API Design: Chaining, Error Messages, and Readability

Both libraries rely on method chaining but differ in details that show up in everyday use. Zod usually passes error messages as the last argument of a method, for example z.string().min(8, "At least 8 characters"), while Yup maintains its own .required("Required field") and .min(8, "At least 8 characters") chain, where required must be set explicitly or the field defaults to optional. This difference in default behavior is a common source of bugs when migrating in either direction.

Zod is stricter about required: a field is mandatory by default, and optional must be explicitly marked via .optional(). This matches the expectations of a type-safe schema more closely, but can lead to unexpectedly required fields when migrating Yup schemas if existing .required() calls are simply dropped. A careful field-by-field comparison is therefore essential for larger forms.

6. Validating Nested Objects and Arrays

Forms with dynamic lists, say multiple contacts or invoice line items, require nested schemas with arrays of objects. Zod expresses this via z.array(z.object({...})) and combines well with React Hook Form's useFieldArray, since both rely on the same path notation for field names. Errors on deeply nested fields land exactly where expected in formState.errors, without additional adjustment.

Yup uses the same basic idea with yup.array().of(yup.object({...})), but behaves more sensitively in practice around missing defaults for nested objects; without an explicit .default(), InferType can produce undefined instead of an object type at that point. Teams working extensively with dynamic, nested forms generally benefit more from Zod's more consistent type derivation, especially when useFieldArray is involved.


import { z } from "zod";
import { useFieldArray, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";

const invoiceSchema = z.object({
  customer: z.string().min(1, "Customer is required"),
  items: z
    .array(
      z.object({
        description: z.string().min(1, "Description is required"),
        amount: z.number().positive("Amount must be positive"),
      })
    )
    .min(1, "At least one line item is required"),
});

type InvoiceFormValues = z.infer<typeof invoiceSchema>;

function InvoiceForm() {
  const { control, register, handleSubmit } = useForm<InvoiceFormValues>({
    resolver: zodResolver(invoiceSchema),
    defaultValues: { customer: "", items: [{ description: "", amount: 0 }] },
  });
  const { fields, append, remove } = useFieldArray({ control, name: "items" });

  return (
    <form onSubmit={handleSubmit((values) => console.log(values))}>
      <input {...register("customer")} />
      {fields.map((field, index) => (
        <div key={field.id}>
          <input {...register(`items.${index}.description`)} />
          <input type="number" {...register(`items.${index}.amount`, { valueAsNumber: true })} />
          <button type="button" onClick={() => remove(index)}>Remove</button>
        </div>
      ))}
      <button type="button" onClick={() => append({ description: "", amount: 0 })}>Add line item</button>
    </form>
  );
}

7. Custom Validation Rules: refine versus test

Once default rules are not enough, say for a password that requires special characters, or a confirmation field that must match the first password, both libraries need an extension mechanism. Zod offers .refine() at the field or object level, which takes a predicate function and an error message and can be combined freely with .superRefine() for multiple error paths at once.

Yup instead uses .test() with a name, message, and test function, conceptually similar but somewhat more cumbersome syntactically, since every test needs a unique name to avoid collisions between multiple tests on the same field. For cross-field checks like password confirmation, Yup also offers .when(), which builds conditional schemas based on other field values, a concept that does not exist in this form in Zod and has to be rebuilt there via object-level .refine().

8. Migration Effort from Yup to Zod

Migrating existing Yup forms to Zod can be done well form by form in practice, since React Hook Form already enforces the separation between UI and validation. The pragmatic path starts with the most frequently changed forms, where the type inference benefit is felt immediately, and leaves stable, rarely touched forms in Yup for now, as long as both resolvers are allowed to coexist in the project.

The real effort rarely lies in simple fields but in the .when() constructs and custom tests that express cross-field logic; these have to be carefully translated into .refine() or .superRefine() and re-validated with the same test cases. A better indicator of a form's migration effort is therefore not its field count but the number of conditional validation rules it contains.

9. Bundle Size, Performance, and Recommendation

For most projects, the runtime difference between Zod and Yup at typical form sizes is barely measurable, both validate synchronously within fractions of a millisecond. Bundle size is more relevant: current versions of Zod are more compact and tree-shakeable, because it consistently relies on functional building blocks instead of a monolithic class hierarchy, which adds up especially in projects with many small forms.

For new React 19 projects with TypeScript, Zod is now the more obvious choice, because its end-to-end type inference catches development errors earlier and ecosystem support, for example for tRPC or OpenAPI generation, has grown broader. Existing Yup projects without an acute pain point do not necessarily need to migrate, though, as long as the team and codebase work productively with the established API.

Criterion Zod Yup Practical Relevance
TypeScript inference Native via z.infer, very precise InferType, gaps with .when() High for complex forms
Required-field default Required by default Optional by default Common migration trap
Conditional validation refine()/superRefine() at object level Field-based .when() Elegance depends on form logic
Bundle size Compact, tree-shakeable Somewhat larger, class-based Relevant with many small forms
Ecosystem tRPC, OpenAPI tooling growing Longer established, many legacy projects Team experience matters too

Mironsoft

React architecture, performance, and Magento frontend integration

React frontends that stay fast instead of slowing down with every feature?

We review existing React applications for unnecessary re-renders, bloated bundles, and fragile state management, then build a frontend that stays performant and connects cleanly to Magento or other backends.

Performance Audit

Systematically measuring and fixing re-renders, bundle size, and load times.

State Architecture

Cleanly separating context, client state, and server state instead of mixing everything.

Magento Integration

Building robust, type-safe GraphQL or REST integration with Magento.

10. Summary

Zod vs. Yup in RHF: The Essentials at a Glance

Type inference

Zod derives types directly via z.infer, Yup needs InferType with gaps around conditional logic.

Resolver swap

zodResolver and yupResolver follow the same pattern, the form code stays largely unchanged.

Required fields

Zod fields are required by default, Yup fields optional by default, the most common migration trap.

Recommendation

New TypeScript projects benefit from Zod, stable Yup forms do not necessarily need to migrate.

11. FAQ: Zod vs. Yup in RHF: The Essentials at a Glance

1Can I use Zod and Yup in the same project at the same time?
Yes, both resolvers can be applied independently per form, as long as each individual form stays consistent with one library. A gradual switch form by form is the common migration strategy.
2Why are Yup fields optional by default while Zod fields are required?
The libraries have different design philosophies: Yup comes from the JavaScript world with loose defaults, Zod was designed with type safety in mind and makes required fields the default case to surface accidentally missing data early.
3Is zodResolver slower than yupResolver?
At typical form sizes the runtime difference is negligible, both validate synchronously within a few milliseconds. Only with very large, deeply nested schemas and many custom rules do small differences become measurable.
4How do I model a form with Zod where required fields depend on the country?
z.discriminatedUnion lets you define a union type with a discriminating field like country, where each branch has its own required fields. The derived TypeScript type narrows automatically in code based on the selected branch.
5Does Yup support asynchronous validation against a backend?
Yes, both Yup and Zod support asynchronous checks via test functions or refine with a promise return value, and both React Hook Form resolvers correctly wait for the result before treating the form as valid.
6What happens to existing error message text during migration?
Error message text can usually be carried over unchanged in both libraries, since it is passed as a string argument to the respective rule. Only the syntax of the rule itself needs adjusting, the text visible to users stays the same.
7Is Zod worth it for small, simple forms too?
For a single contact form the difference is small, both libraries handle the task reliably. Zod's advantage shows up mainly when multiple forms share types or the schema is reused for backend validation.
8Can I use a Zod schema outside of React Hook Form as well?
Yes, Zod schemas can be used independently of React Hook Form, for example to validate API responses or environment variables. This often makes Zod schemas the central type source for an entire project, not just for forms.
9How do I handle nested error messages with useFieldArray?
React Hook Form stores array errors under the same path as the field names, for example errors.items[0].amount. Both zodResolver and yupResolver respect this structure automatically as long as the schema mirrors the same nesting.
10Is there a third alternative besides Zod and Yup?
Yes, for example Valibot as a particularly lightweight, modular alternative, or ArkType with an even more type-driven syntax. For most React 19 projects, though, Zod and Yup remain the most mature and best documented options with an official React Hook Form resolver.