Skip to main content

Zod vs Yup: TypeScript Validation 2026

·PkgPulse Team
0

Zod vs Yup: TypeScript Validation 2026

TL;DR

Zod is the clear winner for TypeScript projects in 2026. Zod (~20M weekly downloads) gives you automatic TypeScript type inference, 3x faster validation, and native support in tRPC, React Hook Form, and the T3 Stack. Yup (~12M downloads) still holds up for Formik-based apps and complex async form validation, but its TypeScript support is bolted-on rather than built-in. If you're starting any TypeScript project today, Zod is the default choice.

Key Takeaways

  • Zod: ~20M weekly downloads vs Yup: ~12M (npm, March 2026)
  • Zod is ~3x faster — 2M ops/sec vs ~700K ops/sec for comparable validations
  • Zod infers TypeScript types automaticallyz.infer<typeof schema> requires zero extra annotation
  • Yup requires manual type declarationsInferType<typeof schema> works but isn't as tight
  • Bundle size: Zod ~13KB gzipped vs Yup ~10KB gzipped — similar, both non-zero
  • tRPC requires Zod — it's the official router input/output validator
  • Yup has better async validation ergonomics.test() is async by default

TypeScript Inference: The Real Difference

This is where Zod and Yup diverge most sharply. Both libraries can produce TypeScript types from schemas, but the quality and ergonomics are fundamentally different.

Zod: Inference Is the Design

Zod was built TypeScript-first. Every schema is a generic type that encodes the validated shape at the type level. When you write a Zod schema, you don't need a separate TypeScript interface — the schema is the type:

import { z } from "zod";

const UserSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(1).max(100),
  email: z.string().email(),
  role: z.enum(["admin", "user", "moderator"]),
  age: z.number().int().min(0).max(150).optional(),
  createdAt: z.date(),
});

// Inferred type — no manual annotation needed
type User = z.infer<typeof UserSchema>;
// type User = {
//   id: string;
//   name: string;
//   email: string;
//   role: "admin" | "user" | "moderator";
//   age?: number | undefined;
//   createdAt: Date;
// }

The inferred type is exact — including the union type for role, the optional age, and the Date type for createdAt. TypeScript will error if you try to assign an incorrect value anywhere that expects a User.

Yup: Inference Works, But With Caveats

Yup added TypeScript support incrementally. The InferType utility exists, but the inferred types are looser:

import * as yup from "yup";

const UserSchema = yup.object({
  id: yup.string().uuid().required(),
  name: yup.string().min(1).max(100).required(),
  email: yup.string().email().required(),
  role: yup.string().oneOf(["admin", "user", "moderator"] as const).required(),
  age: yup.number().integer().min(0).max(150),
  createdAt: yup.date().required(),
});

type User = yup.InferType<typeof UserSchema>;
// type User = {
//   id: string;
//   name: string | undefined;  // ← Yup marks non-required as possibly undefined
//   email: string;
//   role: string | undefined;  // ← Loses the union type precision
//   age: number | undefined;
//   createdAt: Date;
// }

Notice the role field: despite .oneOf(["admin", "user", "moderator"] as const), Yup's InferType returns string | undefined — losing the precise union type. In Zod, z.enum(["admin", "user", "moderator"]) produces the exact "admin" | "user" | "moderator" union. This matters in practice: the Zod version will catch role: "superuser" at compile time; Yup's will not.

Transformation Types

Zod handles type transformations correctly at the TypeScript level:

const ProcessedSchema = z.object({
  rawDate: z.string(),
  tags: z.string().transform((s) => s.split(",")),
  count: z.string().pipe(z.coerce.number()),
});

type Processed = z.infer<typeof ProcessedSchema>;
// type Processed = {
//   rawDate: string;
//   tags: string[];      // ← Correctly inferred as string[]
//   count: number;       // ← Correctly inferred as number, not string
// }

Zod distinguishes between the input type and output type — z.ZodType<Output, Def, Input>. You can introspect both. This is critical for tRPC, which needs to know both the input shape (what comes in) and the output shape (what gets returned) for end-to-end type safety.


Bundle Size and Tree-Shaking

Both Zod and Yup ship as single packages with no peer dependencies. Neither tree-shakes well because validators are typically imported as a namespace.

Side-by-Side Bundle Impact

LibraryMinified + gzippedInstall size
Zod v3~13.1KB~430KB
Yup v1~10.1KB~285KB
Valibot v1~1.4KB (tree-shaken)~410KB

Yup is slightly smaller gzipped, but the difference (3KB) is unlikely to be a practical concern. Neither library has meaningful tree-shaking — if you import Zod for even one validator, you get the full 13KB bundle. Same for Yup.

When bundle size actually matters: If you're building a client-side form validation module for a performance-critical landing page, consider Valibot instead. Valibot's modular imports can get you down to ~1.4KB for simple login forms. For typical React app usage, the 3KB difference between Zod and Yup is within rounding error of other common dependencies.

Server-Side: Bundle Size Doesn't Matter

For Node.js APIs, tRPC backends, or serverless functions with bundling (Webpack, esbuild), the runtime performance and TypeScript ergonomics matter far more than a 3KB gzip difference. Both fit comfortably within Lambda layer limits and Next.js serverless function budgets.


Performance Benchmarks

Zod v3 is approximately 3x faster than Yup for typical validation workloads:

LibrarySimple object (ops/sec)Complex nested (ops/sec)
Zod v3~2,000,000~850,000
Yup v1~700,000~280,000
Zod v4 (beta)~6,000,000+~2,500,000+

Zod v4 is currently in beta with a rewritten core that's dramatically faster — 3-10x faster than Zod v3 depending on the schema type. Yup v1 is unlikely to close this gap given its architecture.

Practical context: At 700K ops/sec, Yup validates ~700 requests per second for a single CPU core — more than enough for any typical application. The performance difference only matters if you're validating large batches of data in tight loops (ETL pipelines, batch imports, streaming data). For HTTP request validation in web APIs, both libraries are fast enough.


React Hook Form Integration

Both libraries work identically with React Hook Form via @hookform/resolvers. The setup is a one-line swap:

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { yupResolver } from "@hookform/resolvers/yup";
import { z } from "zod";
import * as yup from "yup";

// Zod schema
const LoginSchema = z.object({
  email: z.string().email("Enter a valid email"),
  password: z.string().min(8, "Password must be at least 8 characters"),
  rememberMe: z.boolean().default(false),
});
type LoginForm = z.infer<typeof LoginSchema>;

// Yup schema (equivalent)
const LoginSchemaYup = yup.object({
  email: yup.string().email("Enter a valid email").required(),
  password: yup.string().min(8, "Password must be at least 8 characters").required(),
  rememberMe: yup.boolean().default(false),
});

// Component — identical for both
function LoginForm() {
  const { register, handleSubmit, formState: { errors } } = useForm<LoginForm>({
    resolver: zodResolver(LoginSchema),
    // resolver: yupResolver(LoginSchemaYup), // ← swap in, works the same
  });

  return (
    <form onSubmit={handleSubmit((data) => console.log(data))}>
      <input {...register("email")} />
      {errors.email && <p>{errors.email.message}</p>}

      <input {...register("password")} type="password" />
      {errors.password && <p>{errors.password.message}</p>}

      <input {...register("rememberMe")} type="checkbox" />
      <button type="submit">Login</button>
    </form>
  );
}

The real difference shows up with the defaultValues type safety:

// Zod — type is inferred, form is typed
const { register } = useForm<LoginForm>({
  resolver: zodResolver(LoginSchema),
  defaultValues: {
    email: "",
    password: "",
    rememberMe: false,
  },
});
// TypeScript will error on:
// defaultValues: { emial: "" }  ← typo catches as compile error

// Yup — must manually specify generic type
const { register } = useForm<yup.InferType<typeof LoginSchemaYup>>({
  resolver: yupResolver(LoginSchemaYup),
  defaultValues: {
    email: "",
    password: "",
    rememberMe: false,
  },
});

With Zod, z.infer<typeof LoginSchema> flows automatically into useForm<T>. With Yup, you can do the same with yup.InferType, but as noted above, the inferred types may be less precise for complex schemas.


tRPC Integration: Zod Is Required

tRPC uses Zod as its default (and most well-supported) input/output validator. In the T3 Stack and most modern TypeScript full-stack setups, Zod is already in your project via tRPC. Adding Yup for a side use case means carrying two validation libraries.

// tRPC router — Zod is native
import { z } from "zod";
import { router, publicProcedure } from "./trpc";

const PostSchema = z.object({
  title: z.string().min(1).max(200),
  body: z.string().min(1),
  tags: z.array(z.string()).max(5),
  publishedAt: z.date().optional(),
});

export const postRouter = router({
  create: publicProcedure
    .input(PostSchema)
    .mutation(async ({ input }) => {
      // input is fully typed as:
      // { title: string; body: string; tags: string[]; publishedAt?: Date }
      return await db.posts.create(input);
    }),

  list: publicProcedure
    .input(z.object({
      limit: z.number().int().min(1).max(100).default(20),
      cursor: z.string().optional(),
    }))
    .query(async ({ input }) => {
      return await db.posts.findMany({
        take: input.limit,
        cursor: input.cursor ? { id: input.cursor } : undefined,
      });
    }),
});

The end-to-end type safety here is only possible because tRPC understands Zod's generic types. The client automatically knows the return type of postRouter.create.mutate() without any manual type annotation. This doesn't work with Yup because tRPC doesn't understand Yup schemas at the type level.


Async Validation: Yup's Strength

For complex async validation — uniqueness checks, server-side availability, conditional database lookups — Yup's API is cleaner:

// Yup — async test() is first-class
import * as yup from "yup";

const UsernameSchema = yup.string()
  .required("Username is required")
  .min(3, "At least 3 characters")
  .max(20, "At most 20 characters")
  .matches(/^[a-zA-Z0-9_]+$/, "Letters, numbers, underscores only")
  .test(
    "username-available",
    "Username is already taken",
    async (value) => {
      if (!value) return true; // Skip if empty (required handles it)
      const available = await api.checkUsernameAvailability(value);
      return available;
    }
  );

// Clean validation call
const isValid = await UsernameSchema.isValid("alice");

// In React Hook Form — async validation runs on submit/blur
// Zod — async validation via superRefine, less ergonomic
import { z } from "zod";

const UsernameSchema = z.string()
  .min(3, "At least 3 characters")
  .max(20, "At most 20 characters")
  .regex(/^[a-zA-Z0-9_]+$/, "Letters, numbers, underscores only")
  .superRefine(async (value, ctx) => {
    const available = await api.checkUsernameAvailability(value);
    if (!available) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: "Username is already taken",
      });
    }
  });

// MUST use parseAsync — regular parse will ignore async refinements
const result = await UsernameSchema.safeParseAsync("alice");
if (!result.success) {
  console.log(result.error.flatten());
}

Yup's .test() is simpler: return true for valid, false for invalid, or a string for a custom error. Zod's superRefine requires you to call ctx.addIssue() and remember to use safeParseAsync — easy to forget in large codebases and a runtime bug if you use safeParse with async refinements.

For form validation heavy on async checks (unique emails, username availability, invite codes): Yup's ergonomics are meaningfully better.


Error Handling Comparison

// Zod — structured ZodError
const result = z.object({
  email: z.string().email(),
  age: z.number().int().min(0),
}).safeParse({ email: "not-an-email", age: -5 });

if (!result.success) {
  // Flat field errors (great for forms)
  console.log(result.error.flatten().fieldErrors);
  // { email: ["Invalid email"], age: ["Number must be greater than or equal to 0"] }

  // Array of issues with paths
  result.error.issues.forEach((issue) => {
    console.log(issue.path, issue.message, issue.code);
  });

  // Format for UI
  const formatted = result.error.format();
  // { email: { _errors: ["Invalid email"] }, age: { _errors: ["..."] } }
}
// Yup — ValidationError with inner array
import * as yup from "yup";

const schema = yup.object({
  email: yup.string().email().required(),
  age: yup.number().integer().min(0).required(),
});

try {
  await schema.validate({ email: "not-an-email", age: -5 }, { abortEarly: false });
} catch (err) {
  if (err instanceof yup.ValidationError) {
    // Collect all errors as a map
    const errors = err.inner.reduce<Record<string, string>>((acc, e) => {
      if (e.path) acc[e.path] = e.message;
      return acc;
    }, {});
    // { email: "email must be a valid email", age: "age must be greater than or equal to 0" }
  }
}

Zod's safeParse / safeParseAsync never throws — it returns a discriminated union. Yup's validate() throws by default, requiring try/catch. Both approaches work, but Zod's non-throwing API is more composable and easier to handle in async contexts.


Migration: Yup to Zod

If you're on Yup and considering migrating:

// Yup schema (before)
const OldSchema = yup.object({
  name: yup.string().required().min(1).max(100),
  email: yup.string().email().required(),
  age: yup.number().integer().min(0).max(150),
  tags: yup.array(yup.string()).max(5),
  metadata: yup.object().shape({
    source: yup.string().oneOf(["web", "mobile", "api"]).required(),
  }),
});

// Zod equivalent (after)
const NewSchema = z.object({
  name: z.string().min(1).max(100),
  email: z.string().email(),
  age: z.number().int().min(0).max(150),
  tags: z.array(z.string()).max(5),
  metadata: z.object({
    source: z.enum(["web", "mobile", "api"]),
  }),
});

// Note: Zod doesn't use .required() — fields are required by default
// Use .optional() to allow undefined, .nullable() to allow null

The biggest conceptual shift: Zod fields are required by default, and .optional() / .nullable() are explicit. Yup fields are optional by default unless you add .required(). This is actually more sensible for TypeScript — in Zod, the schema's default state matches TypeScript's default state (properties exist unless explicitly optional).


When to Choose

Choose Zod when:

  • TypeScript project (automatic inference is a major DX advantage)
  • Using tRPC or any tRPC-adjacent library
  • Using React Hook Form (either works, but Zod is the community default)
  • Team wants a single validator for both frontend forms and API input validation
  • Running Vitest or Jest tests that need to validate schema shapes

Choose Yup when:

  • Using Formik — Yup is Formik's native validation engine with zero setup
  • Complex async validation is core to your forms (availability checks, API lookups)
  • Existing Yup codebase where migration cost outweighs benefits
  • JavaScript (not TypeScript) project where Zod's type inference isn't relevant

Consider neither when:

  • Bundle size is a top priority — look at Valibot for tree-shaken client-side validation
  • You need JSON Schema portability — look at AJV for language-portable schemas

Compare Zod and Yup package health, download trends, and bundle size on PkgPulse.

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.