Yup vs Zod for Form Validation

Should a new form use Yup or Zod, and if you have hundreds of Yup schemas in a Formik codebase, what actually changes when you move them to Zod? The APIs look similar enough — both chain methods like .string().email().min() — that teams assume a mechanical rename. It is not. The two libraries disagree on whether fields are required by default, whether validation stops at the first error, how paths are reported, when transforms run, and how well TypeScript can infer the result. This recipe compares them on exactly those points, then walks a real schema through a migration with parity tests, keeping error delivery on the site’s baseline of setCustomValidity() plus reportValidity() from the Constraint Validation API.

When This Comparison Matters

  • Choosing for a new project — Zod is the more common choice today for TypeScript codebases because of its inference, ecosystem and Standard Schema support; Yup remains solid and familiar.
  • Maintaining Formik forms — Formik was designed around Yup (validationSchema), and Yup is often the path of least resistance there; see Formik and Yup validation.
  • Migrating — moving to React Hook Form, sharing schemas with a TypeScript server, or adopting Standard Schema are the usual triggers for moving from Yup to Zod.

The broader landscape, including Valibot and ArkType, is in alternative schema libraries.

Behavioural differences between Yup and Zod A table comparing Yup 1 and Zod 3 on required-by-default semantics, error collection, TypeScript inference, path format, unknown keys and Standard Schema support. Yup 1 Zod 3 Fields required by default ✗ .required() ✓ .optional() Collects all errors abortEarly false ✓ always Type inference InferType ✓ z.infer Error path format "items[0].qty" ["items", 0, "qty"] Unknown object keys kept stripped Standard Schema ✗ adapter ✓ 3.24+
The APIs look alike; these defaults are what change behaviour when a schema is migrated.

Side-by-Side Implementation

// Yup 1
import * as yup from "yup";

export const yupProfile = yup.object({
  name: yup.string().trim().required("Enter your name."),
  email: yup.string().trim().email("Enter an email address like name@example.com.").required("Enter your email address."),
  age: yup.number().typeError("Age must be a number.").integer("Age must be a whole number.").min(16, "You must be 16 or older.").required("Enter your age."),
  website: yup.string().url("Enter a web address like https://example.com.").optional(),
  password: yup.string().min(12, "Password must be at least 12 characters.").required("Enter a password."),
  confirm: yup.string().oneOf([yup.ref("password")], "Passwords don't match.").required("Confirm your password."),
});
export type YupProfile = yup.InferType<typeof yupProfile>;

// Zod 3 — the same rules
import { z } from "zod";

export const zodProfile = z
  .object({
    name: z.string().trim().min(1, "Enter your name."),
    email: z.string().trim().min(1, "Enter your email address.").email("Enter an email address like name@example.com."),
    age: z.preprocess(
      (v) => (v === "" ? undefined : v),
      z.coerce.number({ required_error: "Enter your age.", invalid_type_error: "Age must be a number." })
        .int("Age must be a whole number.")
        .min(16, "You must be 16 or older."),
    ),
    website: z.preprocess((v) => (v === "" ? undefined : v), z.string().url("Enter a web address like https://example.com.").optional()),
    password: z.string().min(12, "Password must be at least 12 characters."),
    confirm: z.string().min(1, "Confirm your password."),
  })
  .superRefine((data, ctx) => {
    if (data.password !== data.confirm) ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["confirm"], message: "Passwords don't match." });
  });
export type ZodProfile = z.infer<typeof zodProfile>;

Four translation rules cover most schemas. .required(msg) becomes .min(1, msg) for strings (Zod’s strings are already required, but "" passes). Yup’s .typeError becomes Zod’s invalid_type_error. yup.ref cross-field checks become a superRefine with an explicit path, as in Zod superRefine for cross-field rules. And optional fields that arrive as "" from forms need a preprocess to undefined in Zod, because z.string().url().optional() still validates an empty string as a URL.

// One adapter, both libraries → { fieldName: message }
export async function yupErrors(schema: yup.AnySchema, input: unknown): Promise<Record<string, string>> {
  try {
    await schema.validate(input, { abortEarly: false });
    return {};
  } catch (err) {
    const e = err as yup.ValidationError;
    const out: Record<string, string> = {};
    for (const inner of e.inner) {
      const key = (inner.path ?? "").replace(/\[(\d+)\]/g, ".$1");    // items[0].qty → items.0.qty
      out[key] ??= inner.message;
    }
    return out;
  }
}

export function zodErrors(schema: z.ZodTypeAny, input: unknown): Record<string, string> {
  const r = schema.safeParse(input);
  if (r.success) return {};
  const out: Record<string, string> = {};
  for (const i of r.error.issues) out[i.path.join(".")] ??= i.message;
  return out;
}

// Delivery is identical for both
function apply(form: HTMLFormElement, errors: Record<string, string>): boolean {
  for (const el of form.querySelectorAll<HTMLInputElement>("[name]")) el.setCustomValidity(errors[el.name] ?? "");
  return form.reportValidity();
}
Yup and Zod strengths for forms Two columns listing the practical strengths and weaknesses of Yup and Zod for form validation. Yup 1 ✓ native fit with Formik ✓ yup.ref for sibling references ✗ optional by default: easy to miss .required() ✗ must pass abortEarly: false for all errors ✗ no native Standard Schema Zod 3 ✓ precise TypeScript inference ✓ safeParse never throws ✓ broad ecosystem, Standard Schema ✗ "" passes z.string(): add min(1) ✗ cross-field rules need superRefine
Both validate forms well; the deciding factors are usually TypeScript inference, ecosystem and whether schemas are shared with a server.

Migration Option Reference

Yup Zod equivalent Watch out for
yup.string().required(msg) z.string().min(1, msg) "" is a valid Zod string
yup.number().typeError(msg) z.coerce.number({ invalid_type_error: msg }) "" coerces to 0; preprocess to undefined
.oneOf([yup.ref("x")], msg) superRefine with path Put the issue on the dependent field
.when("field", {...}) superRefine or discriminatedUnion Conditional schemas read differently
schema.validate(v, { abortEarly: false }) schema.safeParse(v) Zod always collects all issues
yup.InferType<T> z.infer<T> / z.input<T> Zod distinguishes input and output types
.noUnknown() default (strip) or .strict() Yup keeps unknown keys by default
.transform(fn) .transform(fn) / z.preprocess Yup transforms run before checks; Zod transforms run after

The last row is the subtlest. Yup’s .transform() runs before validation (it is a cast step), while Zod’s .transform() runs after the preceding checks pass. A Yup schema that trims then validates length must become z.string().trim().min(...) or z.preprocess(trim, ...) in Zod, not .min(...).transform(trim).

Verification Steps: Parity Tests

import { describe, it, expect } from "vitest";
import { yupProfile, zodProfile, yupErrors, zodErrors } from "./profile";

const FIXTURES: Array<Record<string, string>> = [
  { name: "Ada", email: "ada@example.com", age: "36", website: "", password: "correct horse battery", confirm: "correct horse battery" },
  { name: "", email: "not-an-email", age: "abc", website: "nope", password: "short", confirm: "other" },
  { name: " ", email: " ADA@EXAMPLE.COM ", age: "15", website: "https://ada.dev", password: "correct horse battery", confirm: "x" },
];

describe("Yup → Zod parity", () => {
  it.each(FIXTURES)("produces the same errors for %o", async (input) => {
    expect(zodErrors(zodProfile, input)).toEqual(await yupErrors(yupProfile, input));
  });
});

Expect the first run to fail. That is the migration’s value: every failing fixture is a behaviour difference you now know about. Decide for each whether the Zod behaviour is the one you want, and encode that decision in the test.

Migrating one schema from Yup to Zod The migration proceeds by writing the Zod schema alongside Yup, running both over fixtures through adapters, resolving differences, switching the form, and deleting the old schema. Write Zod twin same messages Adapters both → { name: msg } Parity tests real fixtures Resolve diffs fix or accept deliberately Switch + delete form uses Zod only
Running both schemas side by side turns silent behaviour changes into visible, reviewable test failures.

Edge Cases and Failure Modes

Conditional fields with .when(). Yup’s .when("country", { is: "US", then: (s) => s.required() }) is elegant; in Zod it becomes a superRefine that checks the condition and adds an issue with a path, or a z.discriminatedUnion when the condition selects a whole shape. The discriminated union is more type-safe; the refine is closer to Yup’s behaviour.

Nullable versus optional. Yup’s .nullable() and .optional() differ, and many Yup schemas use .nullable() where the form only ever sends "". Decide what an empty field should become (undefined, null or "") and make every migrated field consistent.

Async tests. Yup test() functions can return promises and validate is always async. Zod needs safeParseAsync once any refinement is async. Migrating an async Yup test into a sync safeParse call throws at runtime; the parity test catches it.

Default messages. Yup’s defaults interpolate the path (“email must be a valid email”); Zod’s do not (“Invalid email”). Neither is suitable for users; set explicit messages on every rule so the migration does not change what users read.

Type Inference in Practice

The difference in TypeScript support is easiest to see on an optional field. Yup’s InferType for yup.string().optional() is string | undefined, but for yup.number().nullable() combined with transforms, inference can widen or lose precision, and Yup’s own documentation recommends checking inferred types. Zod distinguishes z.input<typeof schema> (what the form sends, mostly strings) from z.output<typeof schema> (what you get after coercion and transforms), which is exactly the distinction forms need: the input type describes FormData, the output type describes what your submit handler receives. That split is covered in inferring TypeScript types from Zod schemas.

Deciding Whether to Migrate at All

Migration costs time and carries risk; it should buy something specific. Good reasons include sharing schemas with a TypeScript server (Zod’s inference and ecosystem make this smoother), moving from Formik to React Hook Form, adopting Standard Schema so form libraries become interchangeable, or fixing a pattern of bugs caused by Yup’s optional-by-default semantics. “Zod is more popular” is not a reason by itself. If you migrate, do it schema by schema behind an adapter, starting with the forms you are already changing for another reason — the incremental route described in migrating from Formik to React Hook Form.

Frequently Asked Questions

What is the main difference between Yup and Zod for forms?

Defaults. Yup fields are optional unless you call required(), and validate stops at the first error unless abortEarly is false. Zod fields are required by default, safeParse always collects every issue, and TypeScript inference is more precise.

How do I convert yup.ref password confirmation to Zod?

Use superRefine on the object: compare the two fields and add an issue with path set to the confirmation field and your message, so the error lands on the right input.

Why does my migrated Zod schema accept empty fields?

Form inputs send empty strings, and an empty string is a valid Zod string. Replace Yup's required() with min(1, message), and preprocess empty strings to undefined for optional fields.

Should I migrate existing Yup forms to Zod?

Only for a concrete benefit, such as sharing schemas with a TypeScript server or moving to React Hook Form. If you do, migrate schema by schema with parity tests that run both libraries over the same fixtures.

← Back to Alternative Schema Libraries