Skip to main content
← All Articles · · 13 min read

Zod vs Yup vs Joi: TypeScript Schema Validation Libraries Compared 2026

A comprehensive comparison of Zod, Yup, and Joi for TypeScript schema validation in 2026 — covering API design, TypeScript inference, bundle size, performance, and migration guides.

zodyupjoitypescriptschema-validationjavascriptnodejsfrontendbackend

Runtime validation is one of those problems every JavaScript project eventually faces. You receive data from a form, an API, a webhook, or a database — and you need to verify it matches what you expect before you use it. Three libraries dominate this space: Zod, Yup, and Joi. They solve the same problem differently, and the right choice depends heavily on your TypeScript usage and your environment.

This guide compares all three at depth with real code examples, benchmark data, and a migration path from Joi/Yup to Zod.


Quick Comparison: At a Glance

ZodYupJoi
TypeScript inferenceExcellent (first-class)Good (requires separate types)Limited
Bundle size (minified+gzip)~14kb~12kb~28kb (Node.js focused)
EnvironmentUniversal (browser + Node)UniversalNode.js (browser via CDN, heavy)
API styleFluent, chainableFluent, chainableObject-based, descriptive
Async validationYes (.parseAsync())Yes (.validate() is async)Yes (.validateAsync())
Error messagesStructured, customizableCustomizableRich, detailed
Form library integrationReact Hook Form, FormikReact Hook Form, FormikHapi.js (native)
Stars (GitHub, 2026)~35k~22k~21k
First release202020162013
TypeScript-first designYesNo (retrofitted)No (retrofitted)

Zod: TypeScript-First Validation

Zod was built specifically for TypeScript in 2020. Its key differentiator: the schema is the source of truth for both validation and TypeScript types. You write one schema and get both runtime validation and compile-time type safety — no duplication.

Basic Zod Usage

import { z } from 'zod';

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

// Infer TypeScript type from schema — no separate interface needed
type User = z.infer<typeof UserSchema>;
// Result: {
//   id: string;
//   name: string;
//   email: string;
//   age?: number | undefined;
//   role: "admin" | "user" | "moderator";
//   createdAt: string;
// }

// Parse (throws on invalid input)
const user = UserSchema.parse(rawData);

// SafeParse (never throws — returns discriminated union)
const result = UserSchema.safeParse(rawData);
if (result.success) {
  console.log(result.data); // typed as User
} else {
  console.error(result.error.errors); // ZodError[]
}

Advanced Zod Patterns

import { z } from 'zod';

// Nested objects and arrays
const PostSchema = z.object({
  title: z.string().min(5).max(200),
  content: z.string().min(10),
  tags: z.array(z.string()).min(1).max(10),
  author: z.object({
    id: z.string().uuid(),
    name: z.string(),
  }),
  metadata: z.record(z.string(), z.unknown()).optional(),
});

// Union types
const IdSchema = z.union([z.string().uuid(), z.number().int().positive()]);

// Discriminated unions (better TypeScript narrowing)
const EventSchema = z.discriminatedUnion('type', [
  z.object({ type: z.literal('click'), x: z.number(), y: z.number() }),
  z.object({ type: z.literal('keydown'), key: z.string() }),
  z.object({ type: z.literal('scroll'), delta: z.number() }),
]);

// Custom validation with `.refine()`
const PasswordSchema = z.string()
  .min(8, 'At least 8 characters')
  .refine(
    (val) => /[A-Z]/.test(val),
    'Must contain at least one uppercase letter'
  )
  .refine(
    (val) => /[0-9]/.test(val),
    'Must contain at least one number'
  );

// Transform data after validation
const CoercedDate = z.string().transform((val) => new Date(val));

// Async validation (e.g., database uniqueness check)
const UniqueEmailSchema = z.string().email().refine(
  async (email) => {
    const existing = await db.users.findOne({ email });
    return existing === null;
  },
  'Email already in use'
);

const result = await UniqueEmailSchema.safeParseAsync(formData.email);

Zod with React Hook Form

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const ContactSchema = z.object({
  name: z.string().min(2, 'Name must be at least 2 characters'),
  email: z.string().email('Invalid email address'),
  message: z.string().min(10, 'Message must be at least 10 characters'),
});

type ContactForm = z.infer<typeof ContactSchema>;

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

  return (
    <form onSubmit={handleSubmit((data) => console.log(data))}>
      <input {...register('name')} />
      {errors.name && <span>{errors.name.message}</span>}

      <input {...register('email')} />
      {errors.email && <span>{errors.email.message}</span>}

      <textarea {...register('message')} />
      {errors.message && <span>{errors.message.message}</span>}

      <button type="submit">Send</button>
    </form>
  );
}

Zod Strengths and Limitations

Strengths:

  • Type inference eliminates the “write schema AND interface” duplication
  • safeParse returns a discriminated union — TypeScript narrows types automatically
  • Excellent ecosystem support (React Hook Form, Formik, tRPC, Drizzle ORM)
  • Works in browsers and Node.js with no configuration

Limitations:

  • Newer (2020) compared to Joi/Yup — some edge cases still being worked out
  • Verbose for very complex schemas — can get unwieldy without decomposition
  • Error messages are structured but less human-readable out of the box than Joi’s

Yup: The Ecosystem Workhorse

Yup predates TypeScript dominance and was designed for form validation in React. It became wildly popular because Formik shipped with Yup support out of the box. Its API is fluent and readable, and it handles async validation naturally — but TypeScript types must be maintained separately from schemas.

Basic Yup Usage

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(),
  age: yup.number().integer().min(0).max(150),
  role: yup.string().oneOf(['admin', 'user', 'moderator']).default('user'),
});

// Note: You need a separate TypeScript interface
interface User {
  id: string;
  name: string;
  email: string;
  age?: number;
  role: 'admin' | 'user' | 'moderator';
}

// Yup's .validate() is async by default
try {
  const user = await userSchema.validate(rawData, { abortEarly: false });
} catch (error) {
  if (error instanceof yup.ValidationError) {
    console.error(error.errors); // string[]
  }
}

// Or cast (coerce types without validation)
const cast = userSchema.cast(rawData);

Yup can infer types (with limitations)

import * as yup from 'yup';

const schema = yup.object({
  name: yup.string().required(),
  age: yup.number().required(),
});

// InferType extracts TypeScript types — but required/optional inference is imperfect
type UserShape = yup.InferType<typeof schema>;
// Result: { name: string; age: number }
// However, complex inference (unions, transforms) is often incomplete

Advanced Yup Patterns

import * as yup from 'yup';

// Conditional validation with .when()
const shippingSchema = yup.object({
  deliveryType: yup.string().oneOf(['pickup', 'delivery']).required(),
  address: yup.string().when('deliveryType', {
    is: 'delivery',
    then: (schema) => schema.required('Address required for delivery'),
    otherwise: (schema) => schema.optional(),
  }),
});

// Custom test
const positiveEvenSchema = yup.number().test(
  'is-positive-even',
  'Must be a positive even number',
  (value) => value !== undefined && value > 0 && value % 2 === 0
);

// Async test (database check)
const uniqueUsernameSchema = yup.string().test(
  'unique-username',
  'Username already taken',
  async (username) => {
    if (!username) return true;
    const existing = await db.users.findOne({ username });
    return !existing;
  }
);

Yup with Formik

import { Formik, Form, Field, ErrorMessage } from 'formik';
import * as yup from 'yup';

const validationSchema = yup.object({
  email: yup.string().email('Invalid email').required('Email required'),
  password: yup.string().min(8, 'At least 8 characters').required('Password required'),
});

function LoginForm() {
  return (
    <Formik
      initialValues={{ email: '', password: '' }}
      validationSchema={validationSchema}
      onSubmit={(values) => console.log(values)}
    >
      <Form>
        <Field name="email" type="email" />
        <ErrorMessage name="email" />

        <Field name="password" type="password" />
        <ErrorMessage name="password" />

        <button type="submit">Login</button>
      </Form>
    </Formik>
  );
}

Yup Strengths and Limitations

Strengths:

  • Mature and battle-tested (2016, ~22k GitHub stars)
  • Deep Formik integration — most Formik tutorials use Yup
  • .when() conditional validation is expressive
  • Async validation feels natural with .validate()

Limitations:

  • TypeScript inference is incomplete — you often need separate interfaces
  • Cannot use output in TypeScript narrowing as cleanly as Zod’s safeParse
  • Slower than Zod in benchmarks for simple schemas
  • Bundle size comparable to Zod but with fewer features

Joi: The Node.js Server Veteran

Joi was created for the Hapi.js web framework in 2013 and became the gold standard for Node.js server-side validation. It has the richest set of built-in validators and the most human-readable error messages. However, it’s heavy (~28kb gzipped), not designed for TypeScript-first workflows, and its browser support requires bundling the full package.

Basic Joi Usage

import Joi from 'joi';

const userSchema = Joi.object({
  id: Joi.string().uuid().required(),
  name: Joi.string().min(1).max(100).required(),
  email: Joi.string().email().required(),
  age: Joi.number().integer().min(0).max(150),
  role: Joi.string().valid('admin', 'user', 'moderator').default('user'),
});

// Validate synchronously
const { error, value } = userSchema.validate(rawData, { abortEarly: false });

if (error) {
  console.error(error.details); // Joi.ValidationErrorItem[]
  // error.details[0].message: '"email" must be a valid email'
} else {
  console.log(value); // validated and defaulted data
}

// Async validation
const result = await userSchema.validateAsync(rawData, { abortEarly: false });

Advanced Joi Patterns

import Joi from 'joi';

// Conditional validation with .when()
const orderSchema = Joi.object({
  type: Joi.string().valid('physical', 'digital').required(),
  shippingAddress: Joi.when('type', {
    is: 'physical',
    then: Joi.string().required(),
    otherwise: Joi.forbidden(),
  }),
  downloadUrl: Joi.when('type', {
    is: 'digital',
    then: Joi.string().uri().required(),
    otherwise: Joi.forbidden(),
  }),
});

// Custom validation
const customSchema = Joi.number().custom((value, helpers) => {
  if (value % 2 !== 0) {
    return helpers.error('number.even');
  }
  return value;
}).messages({
  'number.even': '{{#label}} must be an even number',
});

// Hapi.js native integration
const server = Hapi.server({ port: 3000 });
server.route({
  method: 'POST',
  path: '/users',
  options: {
    validate: {
      payload: userSchema,
    },
  },
  handler: async (request) => {
    // request.payload is already validated
    const user = await createUser(request.payload);
    return user;
  },
});

Joi Strengths and Limitations

Strengths:

  • Most mature and feature-rich validation library available
  • Excellent for complex server-side validation rules
  • Human-readable, detailed error messages out of the box
  • Native Hapi.js integration
  • Supports strip, convert, presence options for fine control

Limitations:

  • No TypeScript inference — you always need a separate interface
  • Heavy bundle size (impractical for most browser use cases)
  • API verbosity can become unwieldy for large schemas
  • Fewer integrations with modern React form libraries

Performance Comparison

Simple object validation benchmark (1000 iterations, Node.js 22):

LibraryParse 1000 objectsErrors 1000 invalidMemory (heap)
Zod~18ms~45ms~2MB
Yup~35ms~80ms~3MB
Joi~25ms~40ms~5MB

Zod is fastest for valid data parsing. Joi is competitive for invalid data (well-optimized error path). Yup is slowest overall. In practice, validation is rarely a bottleneck — these differences matter only at very high throughput (10k+ validations/sec).


Migrating to Zod

From Yup to Zod

// Before (Yup)
import * as yup from 'yup';

const schema = yup.object({
  name: yup.string().min(2).required(),
  email: yup.string().email().required(),
  age: yup.number().integer().min(0).max(150).optional(),
  tags: yup.array(yup.string()).min(1).required(),
});

// After (Zod) — equivalent schema
import { z } from 'zod';

const schema = z.object({
  name: z.string().min(2),
  email: z.string().email(),
  age: z.number().int().min(0).max(150).optional(),
  tags: z.array(z.string()).min(1),
});

// Bonus: type is inferred automatically
type FormData = z.infer<typeof schema>;

From Joi to Zod

// Before (Joi)
import Joi from 'joi';

const schema = Joi.object({
  username: Joi.string().alphanum().min(3).max(30).required(),
  role: Joi.string().valid('admin', 'user', 'moderator').default('user'),
  metadata: Joi.object().unknown(true).optional(),
});

// After (Zod)
import { z } from 'zod';

const schema = z.object({
  username: z.string().regex(/^[a-z0-9]+$/i, 'Must be alphanumeric').min(3).max(30),
  role: z.enum(['admin', 'user', 'moderator']).default('user'),
  metadata: z.record(z.string(), z.unknown()).optional(),
});

Migration Checklist

  • Replace yup.string().required() → z.string() (Zod strings are required by default; use .optional() for optional)
  • Replace yup.mixed().oneOf([...]) → z.enum([...])
  • Replace yup.object().shape({...}) → z.object({...})
  • Replace try { await schema.validate() } catch {} → schema.safeParse()
  • Remove separate TypeScript interfaces — use z.infer<typeof schema> instead
  • Replace yup.when() conditional → z.discriminatedUnion() or .refine() with cross-field access

Which Should You Choose in 2026?

Choose Zod if:

  • You’re using TypeScript (it was built for TypeScript)
  • You’re building a full-stack TypeScript project (Next.js, tRPC, Remix)
  • You want one source of truth for types and validation
  • You’re using React Hook Form (official Zod resolver)
  • You’re building a new project from scratch

Choose Yup if:

  • Your project uses Formik extensively and migration cost is high
  • You have a large existing Yup codebase
  • Your team is already comfortable with Yup and the project doesn’t use TypeScript heavily

Choose Joi if:

  • You’re building a Node.js/Hapi.js backend with no browser-side validation
  • You need Joi’s advanced features (presence modes, multi-level conditional logic)
  • You’re working on a legacy project that predates Zod/Yup

Summary

In 2026, Zod is the clear default choice for TypeScript projects. It eliminates the schema/type duplication problem that Yup and Joi both suffer from, has excellent ecosystem support, and works in both browsers and Node.js. The TypeScript inference alone justifies the switch for any new project.

Yup remains a solid choice if you’re invested in Formik and don’t need deep TypeScript inference. Joi is the veteran for complex Node.js server validation, particularly if you’re on Hapi.js, but its heavy bundle and lack of TypeScript-first design make it a poor fit for modern frontend work.

If you’re starting a new TypeScript project, use Zod. If you’re maintaining Yup or Joi, consider migrating on the next major version bump — the TypeScript DX improvement is worth it.

Free Download

50 CLI Commands That Save Hours Every Week

The exact Git shortcuts, file search tricks, Docker commands, and productivity hacks that senior developers use daily. Compiled from 5+ years of shipping production code.

  • Git mastery (10 cmds)
  • File & search wizardry
  • Docker & DevOps tricks
  • Process & network tools
  • Productivity boosters
  • macOS & Linux install guide

No spam. 1 email/week max. Unsubscribe anytime.

Found this guide useful? Check out our free developer tools.