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 validate casts input before checking (number() turns "42" into 42 and "" into a type error or undefined depending on configuration; string().trim() transforms). Zod never coerces unless you ask with z.coerce or preprocess.
  • Nullability. Yup distinguishes optional(), nullable(), defined() and required(). 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 with superRefine on the parent object or a discriminated union.
  • Error collection. Yup’s abortEarly: false collects all errors; Zod always collects all issues.
  • Paths. Yup’s ValidationError.inner[].path is a dotted string with bracketed indices (items[2].qty); Zod’s issue path is an array (["items", 2, "qty"]).
Yup concepts and their Zod equivalents Yup string required maps to Zod string min one with a message. Yup number with automatic casting maps to Zod preprocess or coerce, with empty strings handled explicitly. Yup nullable maps to Zod nullable. Yup when conditional rules map to superRefine on the parent object or a discriminated union. Yup oneOf maps to Zod enum. Yup ref for comparing fields maps to superRefine comparing values. The abortEarly false option has no Zod equivalent because Zod always collects all issues. Yup Zod Watch out for string().required() z.string().min(1, msg) "" passes z.string() alone number() (casts) preprocess / z.coerce.number() "" → 0 with coerce nullable() .nullable() optional ≠ nullable when("x", {…}) superRefine on parent / union rule moves up a level oneOf([…]) z.enum([…]) enum needs string literals ref("password") superRefine comparing fields put issue on the right path

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

  1. Inventory Yup features in use. when, ref, test, transform, strip, lazy, custom typeError messages. These are where behaviour lives; plain string().min() translates trivially.
  2. Make Yup’s casting explicit. For every number(), date() and boolean() 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.
  3. Move when and ref rules to the parent object. Use superRefine with issues placed on the dependent field’s path, or a discriminated union when the condition selects whole sets of fields.
  4. Port messages exactly. Keep wording identical during migration so any difference in behaviour stands out.
  5. 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.
  6. 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.

An incremental migration First inventory Yup features in use across forms. Then translate one form's schema to Zod, making casting explicit and moving conditional rules to the parent. Run both schemas on a set of fixture inputs and fix every disagreement. Switch that form's resolver to Zod behind a flag and monitor. Repeat for each form. When no form uses Yup, remove the dependency. Inventory Yup features when, ref, test, casts, strip. Where the behaviour actually lives. Translate one form Explicit casting; rules to parent. Messages copied verbatim. Diff on fixtures compareSchemas(input) until empty. Include blanks, whitespace, hidden conditionals. Switch resolver, repeat, remove Yup One form at a time. Delete the dependency after the last form.

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.

The fixture inputs that catch migration regressions Blank inputs, meaning empty strings in required and optional fields, reveal casting and required differences. Whitespace-only inputs reveal differences in trimming. Hidden conditional fields filled with stale values reveal differences in when and strip behaviour. Numeric strings and non-numeric text in number fields reveal differences between Yup's casting and Zod's strict types. Blank strings Required and optional fields left empty. Whitespace only " " in name and email. Hidden conditionals Company filled, then account switched to personal. Number strings "42", "4 2", "abc", "" in age.

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.


Related

← Choosing a Schema Validation Library