Skip to main content

Zod vs Yup vs Joi 2026: Schema Validation

·PkgPulse Team
0

TL;DR

Zod for TypeScript projects — period. Yup for React form validation with Formik or legacy codebases that already use it. Joi for Express/Hapi API validation when you need maximum expressiveness and don't care about bundle size. Zod's runtime-inferred TypeScript types eliminate an entire class of type drift bugs and it dominates at ~20M weekly downloads. It's the default in tRPC, Conform, and most modern TypeScript stacks. Yup is the go-to for Formik-heavy codebases. Joi is the battle-tested server-side validator from the Hapi ecosystem — the most expressive API, but no TypeScript inference and browser-hostile bundle size.

Quick Comparison

Zod v3Yup v1Joi v17
Weekly Downloads~20M~14M~11M
GitHub Stars~36K~22K~21K
Bundle Size (minified)~14KB~39KB~149KB
TypeScript InferenceFull (native)Partial (v1+)None
Async ValidationYesYes (built-in)Yes
Browser SupportYesYesPartial (heavy)
Form Library IntegrationtRPC, RHF, ConformFormik, RHFServer-only
Error CustomizationGoodGoodExcellent
LicenseMITMITBSD-3

TypeScript Support: The Gap That Defines the Choice

This is the most important axis for 2026 projects. Zod was designed from scratch for TypeScript, and the gap between it and the others is meaningful.

With Zod, your schema IS your type:

import { z } from 'zod';

const UserSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  age: z.number().min(18).max(120),
  role: z.enum(['admin', 'user', 'moderator']),
  createdAt: z.coerce.date(),
});

// TypeScript type is inferred automatically — no manual interface needed
type User = z.infer<typeof UserSchema>;
// Equivalent to:
// type User = {
//   id: string;
//   email: string;
//   age: number;
//   role: "admin" | "user" | "moderator";
//   createdAt: Date;
// }

const result = UserSchema.safeParse(req.body);
if (!result.success) {
  return res.status(400).json({ errors: result.error.issues });
}
const user: User = result.data; // fully typed, no cast needed

With Yup, you write a schema and then add a separate TypeScript type, which can drift:

import * as yup from 'yup';

const userSchema = yup.object({
  id: yup.string().uuid().required(),
  email: yup.string().email().required(),
  age: yup.number().min(18).max(120).required(),
  role: yup.string().oneOf(['admin', 'user', 'moderator']).required(),
});

// Yup v1 has InferType, but enum narrowing is lost:
type User = yup.InferType<typeof userSchema>;
// role is inferred as `string`, not "admin" | "user" | "moderator"

Yup v1 introduced yup.InferType for basic inference, but enum narrowing, discriminated unions, and complex transforms are significantly more limited than Zod's inference. Joi has no TypeScript inference mechanism at all — it predates TypeScript-first design.

The practical consequence: with Zod you define a schema once and get both runtime validation and compile-time types. With Yup or Joi, you maintain two parallel representations of the same data shape, and they can drift when you update one but forget the other.


Bundle Size: Joi's Browser Problem

If you're shipping validation to the browser — form validation, client-side checks, edge functions — bundle size matters significantly.

Zod's minified bundle is ~14KB. For client-side use, this is lightweight enough to be invisible in a bundle audit. Yup is ~39KB minified — still reasonable for browser use, which is why it's the default in Formik and many React form setups. Joi at ~149KB minified is effectively browser-hostile: it's designed for Node.js API validation and the size reflects its comprehensive feature set, including pattern matching, reference expressions, and conditional logic that isn't needed in most browser contexts.

# Bundle size comparison (approximate):
npx bundlephobia zod          # ~14KB minified, ~5KB gzipped
npx bundlephobia yup          # ~39KB minified, ~11KB gzipped
npx bundlephobia joi          # ~149KB minified, ~47KB gzipped

For projects that validate on both client and server, Zod's shared schema — same definition, same types, same bundle size on both sides — is the cleanest solution. With Yup, you can do the same but with weaker TypeScript support. With Joi, you need a separate client-side validation library or you carry ~47KB of gzipped overhead into your bundle.


Validation API Expressiveness

Joi's API has the deepest built-in rule set. Its two decades of development (originally @hapi/joi from the Hapi ecosystem) show in its breadth — particularly for complex server-side scenarios:

const Joi = require('joi');

const schema = Joi.object({
  username: Joi.string().alphanum().min(3).max(30).required(),
  password: Joi.string().pattern(/^[a-zA-Z0-9]{3,30}$/).required(),
  email: Joi.string().email({ minDomainSegments: 2, tlds: { allow: ['com', 'net'] } }),
  // Conditional validation — very natural in Joi:
  accountType: Joi.string().valid('personal', 'business').required(),
  companyName: Joi.when('accountType', {
    is: 'business',
    then: Joi.string().required(),
    otherwise: Joi.string().optional(),
  }),
});

Zod handles most cases with slightly more verbosity for complex conditionals, but with full type safety:

const schema = z.discriminatedUnion('accountType', [
  z.object({
    accountType: z.literal('personal'),
    companyName: z.string().optional(),
  }),
  z.object({
    accountType: z.literal('business'),
    companyName: z.string().min(1), // required when business
  }),
]);
// TypeScript knows which branch you're in — Joi can't do this

Zod's .discriminatedUnion() is type-safe in a way Joi's .when() can't be. But Joi's conditional syntax is more expressive for multi-field interdependencies that don't map cleanly to discriminated unions. For complex server-side rules — cross-field comparisons, nested conditionals, reference expressions — Joi's API is the richest.


Async Validation

All three libraries support async validation, but Yup's async model is the most ergonomic for form scenarios where you need server-round-trips (like checking if a username is already taken):

// Yup async validation — natural in form contexts
const schema = yup.object({
  username: yup.string()
    .required()
    .test('unique-username', 'Username already taken', async (value) => {
      const exists = await checkUsernameExists(value);
      return !exists;
    }),
});

// Validate the whole schema asynchronously
await schema.validate(formData);

Zod's async validation uses .refine() with an async function:

const schema = z.object({
  username: z.string().refine(
    async (value) => !(await checkUsernameExists(value)),
    { message: 'Username already taken' }
  ),
});

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

The APIs are similar in power. Yup's .test() method has been in use longer and is more familiar to developers in Formik codebases. Zod's .refine() is equally capable but requires remembering to use safeParseAsync instead of safeParse when async refinements are present.


Ecosystem Integration

Zod's ecosystem adoption for TypeScript projects is now essentially complete. Key integrations:

  • tRPC: Zod is the default input/output validator
  • React Hook Form: @hookform/resolvers/zod is the most popular resolver
  • Conform (server actions): Zod-native, designed around it
  • Drizzle ORM: drizzle-zod auto-generates schemas from table definitions
  • Next.js Server Actions: Community standard is Zod + zsa or next-safe-action

Yup integrations exist across the same surface area but are the older defaults being gradually replaced:

  • Formik: Originally Yup-native, still the primary integration
  • React Hook Form: @hookform/resolvers/yup is fully supported
  • Migration from Yup to Zod is common when teams modernize TypeScript configs

Joi is a backend validator. Its ~11M weekly downloads are almost entirely Node.js server-side: Express middleware, Hapi route validation, standalone API schemas. It's the standard in codebases built on Hapi or in organizations that standardized on it pre-2020.


When to Use Which

Choose Zod when:

  • You're on any TypeScript project (frontend, backend, or full-stack)
  • You need shared validation between client and server
  • You're using tRPC, Drizzle, Conform, or modern Next.js patterns
  • Type safety and inference are priorities
  • You're starting a new project in 2026

Choose Yup when:

  • Your existing codebase already uses Formik with Yup
  • You're maintaining a React app where migration cost exceeds benefit
  • Mixed TypeScript/JavaScript codebase where inference matters less

Choose Joi when:

  • You're in the Hapi ecosystem
  • You need server-side API validation with complex conditional rules
  • Bundle size is irrelevant (Node.js only)
  • Your team already has expertise in Joi's API

Migration: Yup or Joi to Zod

For common schema patterns, the translation is straightforward:

// Yup → Zod
yup.string().required().min(2)  →  z.string().min(2)
yup.number().positive()         →  z.number().positive()
yup.array().of(yup.string())    →  z.array(z.string())
yup.object({ name: yup.string() }) →  z.object({ name: z.string() })

// Joi → Zod
Joi.string().min(3).max(30)     →  z.string().min(3).max(30)
Joi.number().integer()          →  z.number().int()
Joi.array().items(Joi.string()) →  z.array(z.string())

The main friction points are custom async validators (.test().refine()) and complex .when() conditionals (often need a .superRefine() or .discriminatedUnion() rewrite). For projects that use Joi's reference system (Joi.ref()), the migration requires more thought — Zod doesn't have a direct equivalent.

For new projects in 2026, the choice is Zod by default. The ecosystem, TypeScript support, and bundle size story all point the same direction.

See also: schema validation packages on PkgPulse, Zod vs Valibot: TypeScript Validation in 2026, and Zod vs Yup 2026.

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.