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.
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
| Zod | Yup | Joi | |
|---|---|---|---|
| TypeScript inference | Excellent (first-class) | Good (requires separate types) | Limited |
| Bundle size (minified+gzip) | ~14kb | ~12kb | ~28kb (Node.js focused) |
| Environment | Universal (browser + Node) | Universal | Node.js (browser via CDN, heavy) |
| API style | Fluent, chainable | Fluent, chainable | Object-based, descriptive |
| Async validation | Yes (.parseAsync()) | Yes (.validate() is async) | Yes (.validateAsync()) |
| Error messages | Structured, customizable | Customizable | Rich, detailed |
| Form library integration | React Hook Form, Formik | React Hook Form, Formik | Hapi.js (native) |
| Stars (GitHub, 2026) | ~35k | ~22k | ~21k |
| First release | 2020 | 2016 | 2013 |
| TypeScript-first design | Yes | No (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
safeParsereturns 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,presenceoptions 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):
| Library | Parse 1000 objects | Errors 1000 invalid | Memory (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.
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.
Affiliate disclosure: Some links below are affiliate links — we may earn a small commission at no extra cost to you. Learn more.
Recommended Tools & Resources
DigitalOcean
$200 credit for new users. Simple, affordable cloud hosting for developers.
GitHub Student Pack
Free access to 100+ developer tools. Perfect for students and new devs.
Vercel
Deploy frontend apps instantly. Free tier is generous for side projects.
DevPlaybook Products
Boilerplates, scripts & AI toolkits to 10x your dev workflow.