Both libraries validate data. Both throw when validation fails. The API looks similar enough that it's hard to understand why you'd bother switching. There is one difference that matters a lot in TypeScript, and once you see it, the choice becomes obvious.
With Yup, you define your schema and your TypeScript type separately:
import * as yup from "yup";
const userSchema = yup.object({
name: yup.string().required(),
age: yup.number().required().min(0),
});
// Declared separately
type User = {
name: string;
age: number;
};
// And then asserted
const user = (await userSchema.validate(data)) as UserOccasional notes on software, tools, and things I learn. No spam.
Unsubscribe anytime.
With Zod, the type comes from the schema:
import { z } from "zod";
const userSchema = z.object({
name: z.string(),
age: z.number().min(0),
});
type User = z.infer<typeof userSchema>;
// { name: string; age: number }
const user = userSchema.parse(data); // typed as User, no assertionThis matters more than it looks. With Yup you have two sources of truth: the schema and the TypeScript type. They can drift. Someone adds a field to the schema but forgets to update the interface. In Zod there's one source of truth — the schema is the type.
Yup does have yup.InferType<typeof schema> for inference, but it's less reliable. Transforms can produce string | undefined where you expected string, and optional fields often come out wrong. Zod's inference is strict and correct by default.
For basic shapes, the syntax is close enough that migration isn't hard:
// Yup
const schema = yup.object({
name: yup.string().required(),
email: yup.string().email().required(),
age: yup.number().min(18).optional(),
tags: yup.array(yup.string().required()).required(),
address: yup
.object({
city: yup.string().required(),
})
.required(),
});
// Zod
const schema = z.object({
name: z.string(),
email: z.string().email(),
age: z.number().min(18).optional(),
tags: z.array(z.string()),
address: z.object({
city: z.string(),
}),
});Zod is slightly less verbose — fields are required by default, so no .required() everywhere. Optional fields are explicit with .optional().
Custom validators:
// Yup
const schema = yup
.string()
.test(
"no-spaces",
"Username cannot contain spaces",
(value) => !value?.includes(" "),
);
// Zod
const schema = z
.string()
.refine((value) => !value.includes(" "), {
message: "Username cannot contain spaces",
});Yup throws a ValidationError with an errors array of strings:
try {
await schema.validate(data, { abortEarly: false });
} catch (err) {
if (err instanceof yup.ValidationError) {
console.log(err.errors);
// ["name is a required field", "age must be at least 18"]
}
}Zod throws a ZodError with an issues array of structured objects:
const result = schema.safeParse(data);
if (!result.success) {
console.log(result.error.issues);
// [
// { code: "too_small", path: ["age"], message: "Number must be >= 18" },
// { code: "invalid_type", path: ["name"], message: "Required" }
// ]
}The path field makes it easy to map errors back to form fields or build structured API error responses. With Yup you have to dig through the inner array on the caught ValidationError to get path info. Zod surfaces it directly.
safeParse vs parse is also worth knowing: parse throws on failure, safeParse returns { success: true, data } or { success: false, error } without throwing. Much cleaner for API route handlers.
Historically Zod was slower. That's no longer true for typical object validation — Zod 3.x benchmarks faster than Yup in most real-world cases.
Bundle size: Zod is around 12kb gzipped, Yup is around 17kb. Neither is large enough to be the deciding factor.
Both libraries have official resolvers and the integration is nearly identical:
// Yup
import { yupResolver } from "@hookform/resolvers/yup";
const form = useForm({ resolver: yupResolver(schema) });
// Zod
import { zodResolver } from "@hookform/resolvers/zod";
const form = useForm<z.infer<typeof schema>>({ resolver: zodResolver(schema) });The Zod version lets you pass z.infer<typeof schema> as the form type directly — no separate type declaration needed. Small quality-of-life win, but a real one as schemas evolve.
The shape maps over directly. The main mechanical changes:
.required() calls (Zod fields are required by default).test() with .refine()yup.string().oneOf([...]) with z.enum([...])z.infer<typeof schema>A typical conversion:
// Before (Yup)
const signupSchema = yup.object({
email: yup.string().email().required(),
password: yup.string().min(8).required(),
role: yup.string().oneOf(["admin", "user"]).required(),
});
type SignupData = yup.InferType<typeof signupSchema>;
// After (Zod)
const signupSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
role: z.enum(["admin", "user"]),
});
type SignupData = z.infer<typeof signupSchema>;The resulting type is the same; the schema is shorter, and there's nothing to keep in sync.
Use Zod if:
Stick with Yup if:
If you're in TypeScript, Zod. The type inference is the killer feature and you'll feel it most when schemas evolve and you don't have to remember to update two places.
If you're using Zod in a NestJS project, I covered how to wire it up with ConfigModule for env var validation in Validating NestJS env vars with Zod.