omiid
homenotebookai usage

Zod vs Yup

May 07, 2026 · Updated on August 09, 2026

Yup and Zod both validate data at runtime, and both fail loudly when the data is wrong. The APIs look close enough that switching can seem like churn. One difference is not cosmetic: in TypeScript, Zod derives the static type from the schema and Yup does not.

I write short, practical notes like this one. Get the next one by email:

Unsubscribe anytime.

Zod infers the type; Yup makes you declare it twice

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 User;

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 assertion

This 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, because the schema is the type. For TypeScript schema validation, that is the whole argument.

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.

Zod schemas are shorter because fields are required 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 there is no .required() everywhere, and optional fields are explicit with .optional().

Custom validators use .test() in Yup and .refine() in Zod:

// 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",
	});

Zod errors carry a path; Yup errors are flat strings

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.

Performance and bundle size will not decide this

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.

On bundle size, Zod is around 12kb gzipped and Yup is around 17kb. Neither is large enough to be the deciding factor.

Joi and Valibot sit in the same lane. Joi is Node-first with weaker TypeScript inference, and Valibot trades a smaller bundle for a more verbose API. Neither changes the answer for a TypeScript codebase.

Both work with react-hook-form, but Zod saves you a type

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, with no separate type declaration. Small quality-of-life win, but a real one as schemas evolve.

Migrating a Yup schema to Zod is mechanical

The shape maps over directly. The main changes:

  • Remove .required() calls (Zod fields are required by default)
  • Replace .test() with .refine()
  • Replace yup.string().oneOf([...]) with z.enum([...])
  • Delete separate TypeScript type declarations; replace with 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.

When to use which

Use Zod if:

  • You're in a TypeScript project (frontend or backend)
  • You want one source of truth for your types and validation
  • You're doing API validation, not just form validation
  • You're starting fresh

Stick with Yup if:

  • You're in a JavaScript project with no TypeScript
  • You're already deep in Yup and everything works, so the migration cost isn't worth it unless schemas are actively causing problems
  • Your team is trained on Yup and the project is stable

If you're in TypeScript, Zod. The type inference is the reason, 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.

Join My Newsletter

Occasional notes on software, tools, and things I learn. No spam.

Unsubscribe anytime.

Continue Reading
  • Tuning Postgres and pgvector: the three knobs that matter08-18-2026 · Most pgvector performance problems come down to three settings. This post shows how to read an ANN query plan and tune ef_search, shared_buffers, and work_mem in the right order.
  • HNSW vs IVFFlat: choosing and building your pgvector index08-14-2026 · Past a few hundred thousand rows, an exact scan stops being fast enough. Here is how to pick between HNSW and IVFFlat and build the index without locking the table.
  • Vector search relevance: chunking, metadata, and the 0.81 problem08-11-2026 · Most bad vector search results come from one of three failure modes: chunking, modality mismatch, or a confused model. Each one has a specific diagnostic and a specific fix.
  • pgvector setup: your first multimodal query in TypeScript08-03-2026 · One Postgres table can hold text and screenshot embeddings in the same vector column. This post sets up the schema, the Voyage embedding call, and the first query that returns both.
  • Vector search in Postgres: the mental model behind pgvector08-02-2026 · A first similarity query that returns results is not a finished search feature. This post explains what an embedding is, why one Postgres column can hold text and images, and where an untuned index starts returning wrong results.