Skip to main content

Zod vs Yup 2026: Schema Validation Libraries Compared

·PkgPulse Team
0

TL;DR

Zod for TypeScript projects, especially with tRPC or React Hook Form. Yup for async validation-heavy forms. Zod (~20M weekly downloads) has better TypeScript inference and has overtaken Yup as the community standard. Yup (~12M downloads) has better async validation patterns and a Formik-first API that some teams prefer. For any new TypeScript project, start with Zod.

Key Takeaways

  • Zod: ~20M weekly downloads — Yup: ~12M (npm, March 2026)
  • Zod infers TypeScript types automatically — Yup requires manual type annotation
  • Yup has better async validation — test() methods are async by default
  • Both integrate with React Hook Form — via resolvers
  • Zod has better error handling — structured ZodError with path info

Type Inference: Zod's Biggest Advantage

The most significant reason Zod has overtaken Yup in TypeScript projects is type inference. With Zod, you define your schema once and get a TypeScript type automatically via z.infer<typeof schema>. The schema and the type are the same source of truth — you never define one without the other, and they can never diverge.

With Yup, you define your schema using the fluent method chain, and if you need a TypeScript type (which you do, for form handling, API typing, or passing validated data to other functions), you either infer it with yup.InferType<> or write it manually as a separate interface. The manual approach means your schema and your TypeScript type can drift out of sync — a common source of subtle bugs. yup.InferType<> helps but is not as seamless or as accurate as Zod's inference in complex schemas.

// Yup — schema and TypeScript type are separate concerns
import * as yup from 'yup';

const userSchema = yup.object({
  id: yup.string().uuid().required(),
  name: yup.string().required().min(2),
  email: yup.string().email().required(),
  age: yup.number().min(13).nullable(),
  role: yup.mixed<'admin' | 'user' | 'guest'>()
    .oneOf(['admin', 'user', 'guest'])
    .required(),
});

// Option A: InferType (works, but not as precise as Zod)
type User = yup.InferType<typeof userSchema>;
// User = { id: string; name: string; email: string; age: number | null | undefined; role: ... }

// Option B: Write it manually (common in practice, prone to drift)
interface UserManual {
  id: string;
  name: string;
  email: string;
  age: number | null;
  role: 'admin' | 'user' | 'guest';
}
// Now you have two places to update when the schema changes
// Zod — schema IS the type source of truth
import { z } from 'zod';

const userSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(2),
  email: z.string().email(),
  age: z.number().min(13).nullable().optional(),
  role: z.enum(['admin', 'user', 'guest']),
});

// Type inferred automatically — one source of truth
type User = z.infer<typeof userSchema>;
// User = { id: string; name: string; email: string; age?: number | null; role: 'admin' | 'user' | 'guest' }

// Change the schema → type updates automatically
// No manual sync required, no drift possible

This single advantage drives most of Zod's adoption in TypeScript-first ecosystems. tRPC, Astro, React Hook Form, and many other TypeScript libraries use Zod for this reason — the schema doubles as the type definition.

The practical consequence shows up in day-to-day development. With Zod, you define your API input schema in one place, export the inferred type, and use that same type everywhere — in the frontend form, in the tRPC router, in the database insert. Change the schema and every consumer gets the updated type automatically. With Yup, each of those three places might have slightly different type definitions that need to be kept in sync manually — a category of bug that Zod eliminates entirely.

Zod v4, released in early 2026, added performance improvements and a smaller bundle footprint. The z.string().email() validator now uses a stricter RFC 5322 pattern. Parsing large arrays of objects is measurably faster than v3. If you're on Zod v3 in a production app, upgrading is worth benchmarking for your specific workload.


React Hook Form Integration

Both Zod and Yup integrate cleanly with React Hook Form via the @hookform/resolvers package. The integration pattern is nearly identical — you pass a resolver to useForm(), and validation runs automatically on submit or on change. From the component's perspective, there's no meaningful difference in how errors are surfaced or how the form state behaves.

The practical difference shows up in the schema definition itself. For a typical registration form, the Zod schema is more concise and produces better TypeScript types for the form values. The Yup schema is similarly readable but requires slightly more chaining.

// React Hook Form + Zod (preferred in 2026)
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const signupSchema = z.object({
  username: z.string()
    .min(3, 'Must be at least 3 characters')
    .max(20, 'Must be at most 20 characters')
    .regex(/^[a-zA-Z0-9_]+$/, 'Only letters, numbers, and underscores'),
  email: z.string().email('Must be a valid email'),
  password: z.string().min(8, 'Must be at least 8 characters'),
  confirmPassword: z.string(),
}).refine(
  (data) => data.password === data.confirmPassword,
  { message: 'Passwords must match', path: ['confirmPassword'] }
);

type SignupForm = z.infer<typeof signupSchema>; // Automatically typed

function SignupForm() {
  const { register, handleSubmit, formState: { errors } } = useForm<SignupForm>({
    resolver: zodResolver(signupSchema),
  });

  return (
    <form onSubmit={handleSubmit((data) => api.signup(data))}>
      <input {...register('email')} />
      {errors.email && <span>{errors.email.message}</span>}
      <input {...register('password')} type="password" />
      {errors.password && <span>{errors.password.message}</span>}
      <button type="submit">Sign up</button>
    </form>
  );
}
// React Hook Form + Yup — equally valid
import { yupResolver } from '@hookform/resolvers/yup';
import * as yup from 'yup';

const signupSchema = yup.object({
  username: yup.string()
    .required('Username is required')
    .min(3, 'Must be at least 3 characters')
    .max(20, 'Must be at most 20 characters')
    .matches(/^[a-zA-Z0-9_]+$/, 'Only letters, numbers, and underscores'),
  email: yup.string().required().email('Must be a valid email'),
  password: yup.string().required().min(8, 'Must be at least 8 characters'),
  confirmPassword: yup.string()
    .oneOf([yup.ref('password')], 'Passwords must match'),
});

type SignupForm = yup.InferType<typeof signupSchema>;

function SignupFormYup() {
  const { register, handleSubmit, formState: { errors } } = useForm<SignupForm>({
    resolver: yupResolver(signupSchema),
  });
  // Identical component implementation as Zod version
}

The integration quality is essentially equal. If you're choosing based on form integration alone, the tiebreaker is schema ergonomics and TypeScript quality — where Zod wins.


Async Validation: Yup's Strength

Yup was designed for form validation from the beginning, and its async validation API reflects that. Yup's .test() method accepts an async function natively. You add async tests to any field in the chain, and when you call schema.validate(), Yup awaits all async tests automatically.

Zod's async validation is more of an afterthought. You use .refine() or .superRefine() for custom validation, and async refinements work but require calling parseAsync() instead of parse(). The API works but feels less ergonomic for patterns like checking email uniqueness or username availability against an API.

// Yup — async validation is natural and chainable
import * as yup from 'yup';

const registrationSchema = yup.object({
  username: yup.string()
    .required()
    .min(3)
    .test('username-available', 'Username is already taken', async (value) => {
      if (!value || value.length < 3) return true; // Skip if too short
      const { available } = await fetch(`/api/check-username?u=${value}`).then(r => r.json());
      return available;
    }),
  email: yup.string()
    .required()
    .email()
    .test('email-available', 'Email is already registered', async (value) => {
      if (!value) return true;
      const { available } = await fetch(`/api/check-email?e=${value}`).then(r => r.json());
      return available;
    }),
});

// validate() automatically handles all async tests
try {
  await registrationSchema.validate(formData, { abortEarly: false });
} catch (err) {
  if (err instanceof yup.ValidationError) {
    console.log(err.inner); // Array of all validation errors
  }
}
// Zod — async validation via superRefine (more verbose)
import { z } from 'zod';

const registrationSchema = z.object({
  username: z.string().min(3),
  email: z.string().email(),
}).superRefine(async (data, ctx) => {
  // Must check both fields in a single superRefine or use separate refines
  const [usernameCheck, emailCheck] = await Promise.all([
    fetch(`/api/check-username?u=${data.username}`).then(r => r.json()),
    fetch(`/api/check-email?e=${data.email}`).then(r => r.json()),
  ]);

  if (!usernameCheck.available) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      path: ['username'],
      message: 'Username is already taken',
    });
  }

  if (!emailCheck.available) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      path: ['email'],
      message: 'Email is already registered',
    });
  }
});

// Must use parseAsync() for schemas with async refinements
const result = await registrationSchema.safeParseAsync(formData);

For forms with multiple async validation checks, Yup's per-field .test() API is meaningfully more ergonomic. Each field declares its own async test, making the schema easier to read and maintain. Zod's superRefine approach works but puts all async checks in one place, which can become unwieldy as the schema grows.


Error Handling

Error handling is an area where both libraries are capable but take different approaches. Zod's ZodError contains an errors array where each entry has a path (array of field names for nested errors), message, and code. The .flatten() method converts this into a flat { fieldErrors, formErrors } object that maps directly to what React Hook Form expects. Zod's error structure is designed for programmatic consumption.

Yup's ValidationError has an .inner array for collecting all errors (when abortEarly: false), and each error has .path and .message. The structure is slightly less regular but equally usable. One practical difference: Zod validates all fields by default and returns all errors; Yup stops at the first error by default (controlled by abortEarly option).

// Zod — structured errors, easy to map to form fields
const result = signupSchema.safeParse({
  email: 'not-an-email',
  password: '123',
  username: 'a', // Too short
});

if (!result.success) {
  // Raw errors array
  console.log(result.error.errors);
  // [
  //   { path: ['email'], message: 'Must be a valid email', code: 'invalid_string' },
  //   { path: ['password'], message: 'Must be at least 8 characters', code: 'too_small' },
  //   { path: ['username'], message: 'Must be at least 3 characters', code: 'too_small' },
  // ]

  // Flattened form — ideal for form error display
  const fieldErrors = result.error.flatten().fieldErrors;
  // { email: ['Must be a valid email'], password: ['Must be at least 8 characters'], username: ['...'] }
}
// Yup — collect all errors with abortEarly: false
try {
  await signupSchema.validate(
    { email: 'not-an-email', password: '123', username: 'a' },
    { abortEarly: false } // Collect all errors, not just first
  );
} catch (err) {
  if (err instanceof yup.ValidationError) {
    // Reduce inner errors to field map
    const fieldErrors = err.inner.reduce((acc, e) => ({
      ...acc,
      [e.path!]: e.message,
    }), {} as Record<string, string>);
    // { email: 'Must be a valid email', password: 'Must be at least 8 characters', username: '...' }
  }
}

Schema Composition

Both libraries support building schemas from other schemas, which is important for API-heavy apps where you want to define a base schema and extend it for different contexts.

Zod's composition API is extensive and ergonomic: .extend() adds new fields, .merge() combines two schemas, .pick() and .omit() create subset schemas, .partial() makes all fields optional, .required() makes all fields required. These operations are common when building tRPC routers or REST API input schemas.

// Zod — composable schema API
const BaseUserSchema = z.object({
  name: z.string().min(2),
  email: z.string().email(),
});

// Extend for creation (adds password)
const CreateUserSchema = BaseUserSchema.extend({
  password: z.string().min(8),
});

// Pick subset for update profile
const UpdateProfileSchema = BaseUserSchema.pick({ name: true });

// Make all fields optional for PATCH endpoint
const PatchUserSchema = BaseUserSchema.partial();

// Omit sensitive fields for public responses
const PublicUserSchema = CreateUserSchema.omit({ password: true });

// All schemas are fully typed — TypeScript infers each shape correctly
type CreateUser = z.infer<typeof CreateUserSchema>;
type PublicUser = z.infer<typeof PublicUserSchema>;
// Yup — composition via concat and shape spreading
const baseUserShape = {
  name: yup.string().required().min(2),
  email: yup.string().required().email(),
};

const createUserSchema = yup.object({
  ...baseUserShape,
  password: yup.string().required().min(8),
});

// No built-in pick/omit — recreate manually
const updateProfileSchema = yup.object({
  name: yup.string().min(2),
});

// Yup's composition is less ergonomic for the pick/omit pattern

For backend API validation in TypeScript — especially with tRPC — Zod's composition API is significantly more ergonomic.

The .partial() and .required() patterns are particularly useful for REST APIs. A POST /users endpoint needs all fields required; a PATCH /users/:id endpoint needs all fields optional. With Zod you derive both schemas from one base schema in two lines. With Yup you either duplicate the schema or write a helper to build both variants — more work for the same outcome.

Both libraries also support transforms — running code on the parsed value to normalize it. Zod uses .transform(), Yup uses .transform() too. Common use case: coercing a string "42" to a number, or trimming whitespace from strings. Zod's transform pipeline is slightly cleaner because it's explicit about what type comes out of the transform, ensuring the inferred TypeScript type reflects the transformation.


Zod with tRPC and Full-Stack TypeScript

One ecosystem integration that has no meaningful Yup parallel is tRPC. tRPC uses Zod as its native input validation layer — every tRPC procedure's input() call accepts a Zod schema, which defines both the runtime validation and the TypeScript type for that procedure's input. When you update the Zod schema on the server, the tRPC client automatically picks up the new type through TypeScript's type inference. No API type generation step, no schema duplication, no type drift.

This tRPC integration is the reason many teams building full-stack Next.js apps with tRPC chose Zod and never revisited the question. The entire data layer — from the database query, to the tRPC procedure, to the React component — shares a single Zod schema as its source of truth.

// Zod + tRPC — schema drives types across the full stack
import { z } from 'zod';
import { router, procedure } from '@/trpc';

// Define schema once
const CreatePostSchema = z.object({
  title: z.string().min(1).max(100),
  content: z.string().min(10),
  tags: z.array(z.string()).max(5).optional(),
  published: z.boolean().default(false),
});

// Server: schema validates input, type is inferred automatically
export const postRouter = router({
  create: procedure
    .input(CreatePostSchema)
    .mutation(async ({ input }) => {
      // input is typed as z.infer<typeof CreatePostSchema>
      return db.posts.create({ data: input });
    }),
});

// Client: TypeScript knows the input type from the schema
// No separate type import required — it flows through tRPC
const result = await trpc.post.create.mutate({
  title: 'Hello',
  content: 'This is my first post.',
  // TypeScript enforces the shape here automatically
});

Yup cannot replicate this integration because tRPC's type inference is built around Zod. You could use Yup for client-side form validation and Zod for tRPC procedures in the same app — and some teams do — but it fragments your validation library choice and means you maintain two schema definitions for the same data shape.


Package Health

Metriczodyup
Weekly downloads~20M~12M
GitHub stars~35k~23k
MaintainerColin McDonnelljquense
Latest versionv4 (2026)v1 stable
Bundle size~14KB gzipped~13KB gzipped
TypeScriptFirst-classGood
tRPC integrationNativeVia adapter

Zod v4 was released in early 2026 with significant performance improvements and a smaller bundle size. Yup remains well-maintained with regular updates. Both libraries are stable with long track records. Zod's higher download count reflects its dominance in TypeScript-first ecosystems and adoption by major libraries like tRPC, React Hook Form, and Astro.


When to Choose

Choose Zod when:

  • Building a TypeScript project where type safety matters — automatic z.infer<> is a significant DX win
  • Using tRPC — Zod is the native input validation library
  • Using React Hook Form for form validation
  • Building API routes where schema doubles as the input type
  • You want composable schemas with pick, omit, extend, partial operations
  • Starting a new project without existing validation library commitment

Choose Yup when:

  • Using Formik — Yup has native first-class integration via validationSchema prop
  • Your forms have complex async validation that needs per-field test chaining
  • You have an existing Yup codebase with significant validation logic built up
  • Building a JavaScript (not TypeScript) project where Zod's type inference advantage disappears
  • Your team is more familiar with Yup's method-chaining API

For a full package health comparison, see the Zod vs Yup comparison page. If you're evaluating Zod alongside other TypeScript validation options, the Zod vs TypeBox article covers the JSON Schema-compatible alternative. Current Zod download trends and release notes are on the Zod package page.

See the live comparison

View zod vs. yup on PkgPulse →

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.