---
title: "Zod vs Yup"
description: "Yup is fine for forms. For validating API payloads in TypeScript, Zod fits better because the schema is the type. The difference, and when to switch."
canonical: https://omiid.me/notebook/5/zod-vs-yup
author: Omid Sayfun
published: 2026-05-07
updated: 2026-08-09
---

# Zod vs Yup

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.

## Zod infers the type; Yup makes you declare it twice

With Yup, you define your schema and your TypeScript type separately:

```typescript
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:

```typescript
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:

```typescript
// 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:

```typescript
// 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:

```typescript
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:

```typescript
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:

```typescript
// 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:

```typescript
// 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](https://omiid.me/notebook/38/validating-nestjs-env-vars-with-zod).
