Teams move from Yup to Zod for TypeScript inference, and the mechanical API swap takes an afternoon — then forms start behaving differently, because Yup casts values by default (an empty string becomes undefined for numbers, "1" becomes 1) while Zod checks types strictly, and conditional rules written with Yup’s when have no direct equivalent.
A behaviour-preserving migration maps the concepts, not just the method names, and verifies each form against both schemas before switching. This page, part of choosing a schema validation library, covers that mapping and an incremental rollout.
Context and prerequisites
The conceptual differences that matter for forms:
- Casting. Yup’s
validatecasts input before checking (number()turns"42"into42and""into a type error orundefineddepending on configuration;string().trim()transforms). Zod never coerces unless you ask withz.coerceorpreprocess. - Nullability. Yup distinguishes
optional(),nullable(),defined()andrequired(). Zod has.optional(),.nullable()and.nullish(); “required” is the absence of those, plus.min(1)for non-empty strings. - Conditionals. Yup’s
.when("other", { is, then, otherwise })makes a field’s rules depend on another field. Zod expresses this withsuperRefineon the parent object or a discriminated union. - Error collection. Yup’s
abortEarly: falsecollects all errors; Zod always collects all issues. - Paths. Yup’s
ValidationError.inner[].pathis a dotted string with bracketed indices (items[2].qty); Zod’s issuepathis an array (["items", 2, "qty"]).
The core pattern: a translated schema and a side-by-side check
// BEFORE: Yup
import * as yup from "yup";
export const signupYup = yup.object({
accountType: yup.mixed<"personal" | "business">().oneOf(["personal", "business"]).required(),
company: yup.string().when("accountType", {
is: "business",
then: (s) => s.required("Enter your company name."),
otherwise: (s) => s.strip(),
}),
age: yup.number().typeError("Enter your age as a number.").min(16, "You must be 16 or over.").required("Enter your age."),
password: yup.string().min(12, "Use at least 12 characters.").required("Create a password."),
confirm: yup.string().oneOf([yup.ref("password")], "Passwords do not match.").required("Re-enter your password."),
});
// AFTER: Zod — same behaviour, made explicit
import { z } from "zod";
const emptyToUndefined = (v: unknown) => (typeof v === "string" && v.trim() === "" ? undefined : v);
export const signupZod = z.object({
accountType: z.enum(["personal", "business"], { required_error: "Choose an account type." }),
company: z.string().trim().optional(),
// Yup's cast, made explicit: "" → missing, "42" → 42, "abc" → type error.
age: z.preprocess((v) => { const e = emptyToUndefined(v); return typeof e === "string" && e !== "" && !Number.isNaN(Number(e)) ? Number(e) : e; },
z.number({ required_error: "Enter your age.", invalid_type_error: "Enter your age as a number." })
.min(16, "You must be 16 or over.")),
password: z.string().min(1, "Create a password.").min(12, "Use at least 12 characters."),
confirm: z.string().min(1, "Re-enter your password."),
}).superRefine((v, ctx) => {
// Yup's when(): the conditional rule moves to the parent.
if (v.accountType === "business" && !v.company) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["company"], message: "Enter your company name." });
}
// Yup's ref(): cross-field comparison on the parent.
if (v.confirm && v.password !== v.confirm) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["confirm"], message: "Passwords do not match." });
}
}).transform((v) => (v.accountType === "business" ? v : { ...v, company: undefined })); // Yup's strip()
// During migration: run both on real inputs and report any disagreement.
export async function compareSchemas(input: Record<string, unknown>) {
const yupErrors = await signupYup.validate(input, { abortEarly: false }).then(() => ({}))
.catch((e: yup.ValidationError) => Object.fromEntries(e.inner.map((i) => [i.path, i.message])));
const z = signupZod.safeParse(input);
const zodErrors = z.success ? {} : Object.fromEntries(z.error.issues.map((i) => [i.path.join("."), i.message]));
const keys = new Set([...Object.keys(yupErrors), ...Object.keys(zodErrors)]);
return [...keys].filter((k) => (yupErrors as any)[k] !== (zodErrors as any)[k])
.map((k) => ({ field: k, yup: (yupErrors as any)[k], zod: (zodErrors as any)[k] }));
}
Step-by-step walkthrough
- Inventory Yup features in use.
when,ref,test,transform,strip,lazy, customtypeErrormessages. These are where behaviour lives; plainstring().min()translates trivially. - Make Yup’s casting explicit. For every
number(),date()andboolean()field, add the preprocessing that reproduces Yup’s cast — empty to missing, numeric strings to numbers — as in coercing form strings with Zod preprocess and coerce. - Move
whenandrefrules to the parent object. UsesuperRefinewith issues placed on the dependent field’s path, or a discriminated union when the condition selects whole sets of fields. - Port messages exactly. Keep wording identical during migration so any difference in behaviour stands out.
- Run both schemas side by side. In development and tests, validate real and fixture inputs with both and report disagreements; fix until the list is empty.
- Switch per form, then remove Yup. Resolvers make this a one-line change per form (
yupResolver→zodResolver); remove Yup once the last form moves.
Why behaviour, not syntax, is the migration
A migration that only translates method calls will pass type checks and most tests, because tests usually cover valid input and one or two obvious errors. The regressions hide in the edges Yup handled implicitly: an optional number field left empty, a whitespace-only name, a conditional field hidden by an earlier answer. Users meet those edges on the first day. Treating the old schema as the specification — and diffing the new one against it on a corpus of real inputs, including recorded submissions if you have them — catches the regressions before users do, and turns implicit behaviour into explicit, reviewed code.
Failure modes and edge cases
1. Empty strings that used to be undefined
Yup’s number() treats "" as a cast failure (or undefined with .transform), while z.string() accepts "" as a valid string. Required text fields need .min(1) in Zod — the single most common regression.
2. .strip() and unknown keys
Yup can strip fields conditionally; Zod’s .strip() removes unknown keys only. Reproduce conditional stripping with a .transform on the parent, or rely on your payload builder’s relevance rules.
3. Refinements that break .pick and .partial
Once the object has superRefine, you cannot .pick steps from it. Keep a base object without refinements, as in partial Zod schemas for drafts and wizard steps.
4. Error path formats
Code that reads errors["items[2].qty"] from Yup breaks with Zod’s items.2.qty. Normalise paths in one adapter during migration.
5. Async tests
Yup’s test supports async functions naturally. Zod supports async refinements with parseAsync; if the form library calls the synchronous parse, async refinements throw. Check that your resolver uses async parsing, or move remote checks out of the schema.
Verification checklist
Frequently Asked Questions
Is migrating worth it if Yup works?
If your team is on TypeScript and maintaining separate types for form values, Zod’s inference removes that duplication and catches mismatches at compile time. If types are not a pain point, the migration risk may not be worth it.
Can Yup infer TypeScript types too?
Yes — Yup v1 offers InferType and improved typings. The gap has narrowed; Zod’s inference is still more precise for unions, transforms and input versus output types, which matters for forms with coercion.
Should I go straight to Valibot instead?
If bundle size is the priority, possibly. The same behaviour-first process applies; see migrating from Zod to Valibot for the API differences, which are larger than Yup-to-Zod.