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.
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();
}
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.
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.
Related Guides
- Alternative Schema Libraries — the wider comparison.
- Formik and Yup Validation — where most Yup schemas live.
- Using Zod for Complex Form Schemas — the Zod side in depth.
- Standard Schema: Library-Agnostic Validation — making the next migration unnecessary.