JavaScript has changed a lot in the past few years. Today, you can access powerful tools, such as data validation, that help you check if your data is correct. Two popular libraries for this are Joi and Zod.
Joi has been around for a while. It started as part of the Hapi.js framework when Node.js was becoming popular. Joi works well in both JavaScript and TypeScript. It gives you lots of features and flexibility, which is why many developers still rely on it in their Node.js apps.
Zod is newer. It was created in 2020 and is built with TypeScript in mind. If you're using TypeScript, Zod helps ensure your code matches both at runtime (when your app is running) and compile time (when your code is being checked before it runs). But don’t worry—Zod also works in regular JavaScript projects.
This comparison will help you clearly decide whether Zod or Joi is a better fit for your project—whether you're using JavaScript, TypeScript, or a mix of both.
What is Joi?
Joi started as part of the Hapi.js framework in the Node.js world. The goal was to make a validation library that feels natural for JavaScript developers and works well in Node.js and browser apps.
Joi uses a clear, readable style to define rules. You create validation schemas by chaining methods like .min()
, .max()
, .required()
, and .valid()
. This style makes it easy to understand what each rule is doing, even if you're not using TypeScript.
Even though Joi started with Hapi.js, it now works with many popular frameworks like Express, Fastify, and NestJS. It’s an excellent choice for JavaScript projects but also supports TypeScript with type definitions.
What is Zod?
Zod is a modern validation library that works great in both JavaScript and TypeScript. With Zod, you write your validation rules once and use them to check data in any JavaScript or TypeScript project.
If you're using TypeScript, Zod can automatically create types for you using z.infer<>
, so you don’t need to write types separately.
Even if you don’t use TypeScript, Zod still gives you clear error messages, reusable validation rules (called schemas), and tools to transform data. Its error messages are easy to read and handle, which helps you create better user feedback and cleaner API responses in any kind of project.
Joi vs. Zod: a quick comparison
Choosing between Joi and Zod affects how you write and manage validation in your project. Each library takes a different approach, especially when working with TypeScript.
Here’s a quick look at the main differences to help you decide:
Feature | Joi | Zod |
---|---|---|
Primary approach | Schema-based validation with separate type definitions | Schema-based validation with unified type inference |
TypeScript integration | Added via declaration files and helpers | Native TypeScript-first design |
Schema definition | Chainable API with method calls | Object-oriented builder pattern |
Error handling | Detailed, customizable error messages | Structured error objects with path information |
Learning curve | More verbose but intuitive for beginners | Concise but requires TypeScript knowledge |
Custom validators | Supports custom functions via custom() |
Supports transforms and refinements |
Performance | Optimized over years of refinement | Comparable to Joi with less overhead |
Type inference | Limited, often requires manual type definitions | Full static type inference from schemas |
Ecosystem maturity | Mature with extensive plugins and integrations | Growing quickly with active development |
Bundle size | Larger due to extensive feature set | Smaller with better tree-shaking support |
Framework integrations | Express, Hapi, Fastify, NestJS | NextJS, tRPC, React Hook Form, NestJS |
Transformation capabilities | Data transformation via transform() |
First-class transformation with parsing |
Schema definition
The way you define validation schemas shapes how you think about data and how easily validation fits into your app. Joi and Zod take different approaches based on their origins and goals.
Joi introduced a fluent, chainable style for schema definition. You build validation rules step by step, adding constraints with each method. This creates readable, almost sentence-like code that works well in both JavaScript and TypeScript:
const Joi = require('joi');
const userSchema = Joi.object({
id: Joi.number().integer().positive().required(),
email: Joi.string().email().required(),
name: Joi.string().min(2).max(100),
role: Joi.string().valid('admin', 'user', 'guest').default('user'),
tags: Joi.array().items(Joi.string()),
metadata: Joi.object().unknown(true)
});
const { error, value } = userSchema.validate({
id: 1,
email: 'user@example.com',
name: 'John Doe',
tags: ['developer']
});
if (error) {
console.error(error.message);
} else {
console.log(value);
}
Joi is JavaScript-first. It doesn’t generate TypeScript types automatically, but it does come with type definitions for its own API. If you want to generate types from your schemas, you can use tools like joi-to-typescript
, though this isn’t built into Joi itself.
Zod, on the other hand, is built with both JavaScript and TypeScript in mind. It offers a simple, clean API and adds a big advantage for TypeScript users: automatic type inference. You define your schema once, and TypeScript can generate types directly from it:
import { z } from 'zod';
const userSchema = z.object({
id: z.number().int().positive(),
email: z.string().email(),
name: z.string().min(2).max(100).optional(),
role: z.enum(['admin', 'user', 'guest']).default('user'),
tags: z.array(z.string()).optional(),
metadata: z.record(z.string(), z.any()).optional()
});
type User = z.infer<typeof userSchema>;
try {
const user = userSchema.parse({
id: 1,
email: 'user@example.com',
name: 'John Doe',
tags: ['developer']
});
console.log(user);
} catch (error) {
console.error(error);
}
Zod works well even in pure JavaScript projects, but it shines in TypeScript environments. It saves you from writing types separately and helps keep your codebase consistent. This makes it a great choice if you're already using TypeScript or plan to switch later.
The main difference comes down to focus. Joi offers a rich, expressive API that’s familiar to JavaScript developers, while Zod gives you a compact syntax with deep TypeScript integration.
Both can handle complex validation, but they trade off differently in terms of readability, tooling, and type support.
Validation patterns
The way validation runs at runtime affects your app's structure, error handling, and how you organize code. Joi and Zod follow different patterns that influence where and how you write validation logic.
Joi uses an "inspection-based" model. When you call .validate()
, it returns an object with both the validation result and any errors. This non-throwing approach gives you full control over how to handle failures without breaking your code flow:
import Joi from 'joi';
const loginSchema = Joi.object({
email: Joi.string().email().required(),
password: Joi.string().min(8).required(),
rememberMe: Joi.boolean().default(false)
});
function processLogin(data: unknown) {
const { error, value } = loginSchema.validate(data, {
abortEarly: false,
stripUnknown: true
});
if (error) {
const formattedErrors = error.details.map(err => ({
field: err.path.join('.'),
message: err.message
}));
return { success: false, errors: formattedErrors };
}
return { success: true, user: value };
}
Joi also supports async validation through .validateAsync()
, which is helpful when checking values against a database or an external API:
const asyncSchema = Joi.object({
username: Joi.string().external(async (value) => {
const exists = await checkUserExists(value);
if (exists) throw new Error('Username already taken');
return value;
})
});
asyncSchema.validateAsync({ username: 'newuser' })
.then(value => console.log('Valid:', value))
.catch(err => console.error('Invalid:', err.message));
Zod uses a "parse or throw" pattern. When you call .parse()
, it either returns the validated data or throws a ZodError
. This aligns well with TypeScript, since successful parsing narrows the type automatically:
import { z } from 'zod';
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
rememberMe: z.boolean().default(false)
});
type LoginData = z.infer<typeof loginSchema>;
function processLogin(data: unknown) {
try {
const validData = loginSchema.parse(data);
return { success: true, user: validData };
} catch (error) {
if (error instanceof z.ZodError) {
const formattedErrors = error.errors.map(err => ({
field: err.path.join('.'),
message: err.message
}));
return { success: false, errors: formattedErrors };
}
throw error;
}
}
If you prefer not to throw on invalid data, Zod offers .safeParse()
. It returns a success flag and either the validated data or the error object:
function processLoginSafe(data: unknown) {
const result = loginSchema.safeParse(data);
if (result.success) {
return { success: true, user: result.data };
} else {
const formattedErrors = result.error.errors.map(err => ({
field: err.path.join('.'),
message: err.message
}));
return { success: false, errors: formattedErrors };
}
}
Zod's pattern fits TypeScript especially well. When validation succeeds, the data is automatically typed, reducing the need for manual assertions. This leads to cleaner, safer code where TypeScript helps catch mistakes early. Joi, while powerful, often requires extra steps to achieve similar type safety.
Handling complex validation scenarios
Real-world applications often need more than simple field checks. You might need conditional rules, field dependencies, business logic, or data transformations. Joi and Zod both support these use cases, but they approach them differently.
Joi is well-known for its ability to handle complex validation. Its API has matured over years of production use and includes built-in tools for field relationships, conditional rules, and context-aware validation:
const resetPasswordSchema = Joi.object({
password: Joi.string()
.min(8)
.pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
.required()
.messages({
'string.pattern.base': 'Password must contain at least one uppercase letter, one lowercase letter, and one number'
}),
confirmPassword: Joi.string().required(),
token: Joi.string().required()
}).custom((value, helpers) => {
if (value.password !== value.confirmPassword) {
return helpers.error('passwords.mismatch');
}
return value;
}).messages({
'passwords.mismatch': 'Passwords do not match'
});
const userUpdateSchema = Joi.object({
email: Joi.string().email(),
notificationPreferences: Joi.object({
email: Joi.boolean(),
sms: Joi.boolean(),
phone: Joi.string().when('sms', {
is: true,
then: Joi.string().pattern(/^\+[1-9]\d{1,14}$/).required(),
otherwise: Joi.string().optional()
})
})
});
Joi includes methods like .when()
, .custom()
, and .alternatives()
for defining complex conditions and logic directly in the schema. Its error messaging is detailed and customizable, helping create helpful responses in user-facing apps.
Zod takes a more functional, composable approach. Instead of offering many built-in tools, Zod relies on generic methods like .refine()
and .transform()
that you can combine to create custom validation flows:
const resetPasswordSchema = z.object({
password: z
.string()
.min(8)
.regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, {
message: 'Password must contain at least one uppercase letter, one lowercase letter, and one number'
}),
confirmPassword: z.string(),
token: z.string()
}).refine(data => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword']
});
const userUpdateSchema = z.object({
email: z.string().email().optional(),
notificationPreferences: z.object({
email: z.boolean().optional(),
sms: z.boolean().optional(),
phone: z.string().optional()
}).transform(prefs => {
if (prefs.sms && (!prefs.phone || !/^\+[1-9]\d{1,14}$/.test(prefs.phone))) {
throw new Error('Valid phone number is required when SMS is enabled');
}
return prefs;
})
});
Zod’s .refine()
lets you add logic that depends on multiple fields, while .transform()
allows modifying data during validation. These tools give you flexibility while keeping schemas clean and type-safe.
Async validation also highlights the differences. Joi integrates async checks directly using .external()
and .validateAsync()
:
const usernameSchema = Joi.string().external(async (value) => {
const exists = await checkUserExists(value);
if (exists) throw new Error('Username already taken');
return value;
});
Zod separates sync and async validation. For async checks, you must use .parseAsync()
and define async refinements:
const usernameSchema = z.string().refine(
async (username) => {
const exists = await checkUserExists(username);
return !exists;
},
{ message: 'Username already taken' }
);
async function validateUsername(username: string) {
try {
await usernameSchema.parseAsync(username);
return { valid: true };
} catch (error) {
return { valid: false, error };
}
}
In short, Joi offers a wide range of built-in features tailored for common patterns, which can make complex schemas faster to write. Zod takes a simpler, more flexible approach using composition, which leads to more reusable and type-safe code—especially in TypeScript-heavy projects.
Type safety and integration
If you're using TypeScript, how a validation library works with the type system matters. For JavaScript-only projects, this isn’t a concern—but for TypeScript teams, integration can impact workflow and code quality.
Joi was built before TypeScript became mainstream, but has since added support. It includes type definitions for its API, which helps TypeScript understand how Joi works. However, you still need to define your own types separately:
import Joi from 'joi';
const userSchema = Joi.object({
id: Joi.number().required(),
email: Joi.string().email().required(),
name: Joi.string().required(),
age: Joi.number().min(18).optional()
});
interface User {
id: number;
email: string;
name: string;
age?: number;
}
function processUser(data: unknown): User {
const { error, value } = userSchema.validate(data);
if (error) {
throw new Error(`Invalid user data: ${error.message}`);
}
return value as User;
}
With Joi, validation and typing are separate. This gives you flexibility, but it also means more maintenance since you need to keep schemas and types in sync manually. Still, it’s a common and manageable setup, especially in teams slowly moving from JavaScript to TypeScript.
Zod takes a different approach. It works in JavaScript too, but for TypeScript, it automatically turns schemas into types—so validation and type safety stay in sync:
import { z } from 'zod';
const userSchema = z.object({
id: z.number(),
email: z.string().email(),
name: z.string(),
age: z.number().min(18).optional()
});
type User = z.infer<typeof userSchema>;
function processUser(data: unknown): User {
return userSchema.parse(data);
}
Zod’s schema-to-type system eliminates the need to define types separately. This keeps code DRY and prevents mismatches between validation and typing—especially useful in large or type-heavy projects.
Here’s how the two compare in practice:
JavaScript-only projects: Both libraries work great. TypeScript integration doesn’t matter here—choose based on features and style.
Mixed JS/TS codebases: Joi may feel more familiar, as it lets you adopt TypeScript gradually. Zod needs a more deliberate shift but can simplify things long term.
TypeScript-first projects: Zod shines here by combining validation and types in one place, cutting down on duplication and errors.
Planning a migration to TypeScript: Zod provides a smoother path. You can use it in JavaScript now, and once you switch to TypeScript, your schemas instantly provide types.
While type safety is important, it’s just one piece of the puzzle. API design, ecosystem support, and team preference all play a role in choosing the right tool.
Ecosystem and maturity
A validation library isn’t just about syntax—it lives in an ecosystem of tools, documentation, community support, and integrations. The surrounding environment is significant in long-term productivity and ease of use.
Joi has been around since 2012 and is one of the most established validation libraries in the JavaScript world. Its documentation is deep, shaped by years of real-world use. It's widely adopted in enterprise environments, especially in industries with strict data requirements.
Frameworks like Express, Hapi, and Fastify have mature Joi integrations, often through official middleware. There’s also a healthy ecosystem of Joi extensions—like joi-date
and joi-phone-number
—for domain-specific needs. Joi's API has remained stable despite its age, with changes introduced slowly to avoid breaking existing code.
This long-standing integration with server-side JavaScript makes Joi a natural fit for RESTful APIs and backend applications. It works especially well in Express-like frameworks, where validation happens at the middleware level:
import express from 'express';
import Joi from 'joi';
import { celebrate, Segments } from 'celebrate';
const app = express();
const userSchema = Joi.object({
username: Joi.string().alphanum().min(3).max(30).required(),
email: Joi.string().email().required()
});
app.post('/users',
celebrate({ [Segments.BODY]: userSchema }),
(req, res) => {
const user = req.body;
res.status(201).json({ id: 'new-user-id', ...user });
}
);
app.use((err, req, res, next) => {
if (err.joi) {
return res.status(400).json({ error: err.joi.message });
}
next(err);
});
Zod, launched in 2020, is much newer but has quickly built a strong ecosystem, especially among TypeScript developers. Its documentation is tailored for TypeScript, focusing on type inference and integration tips.
Modern frameworks like tRPC, React Hook Form, and Remix have embraced Zod as their default validation tool. It also pairs well with functional TypeScript libraries like ts-pattern
and fp-ts
, helping developers build type-safe systems end to end. As Zod gains traction, editor tools and plugins are improving support, making development smoother.
Zod’s ecosystem is built around modern TypeScript practices and full-stack apps that share types between client and server. It's a natural fit for teams using tools like Next.js or tRPC:
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.create();
const userSchema = z.object({
username: z.string().min(3).max(30),
email: z.string().email()
});
const appRouter = t.router({
createUser: t.procedure
.input(userSchema)
.mutation(async ({ input }) => {
const user = input;
return { id: 'new-user-id', ...user };
}),
});
export type AppRouter = typeof appRouter;
The ecosystems reflect different strengths. Joi is reliable, battle-tested, and ideal for backend systems built on traditional REST APIs. It’s handy in large, mature codebases. Zod, on the other hand, is built for modern TypeScript workflows, where type safety and shared schemas across the stack are key.
Joi focuses on flexible, powerful server-side validation with rich error handling. Zod focuses on keeping your types and validation in sync across the entire app, making it a strong choice for building full-stack TypeScript systems.
Performance considerations
Validation happens constantly—on every API request, form submission, and data transformation—so performance matters. While validation rarely becomes a major bottleneck, understanding how your library handles performance can help with scalability and responsiveness, especially in high-traffic or client-heavy apps.
Joi is built for flexibility and power, and its performance reflects that. It precompiles schemas into fast validation functions and uses internal caching to avoid repeating work. Options like abortEarly
can speed up validation by stopping at the first error. That said, Joi’s feature-rich design comes with a larger memory footprint, which is something to keep in mind for browser use or when bundle size matters.
import Joi from 'joi';
const schema = Joi.object({
/* schema definition */
}).options({
abortEarly: true,
cache: true
});
function validateMany(items) {
return items.map(item => schema.validate(item));
}
Zod has a more lightweight and focused architecture. Its smaller API and TypeScript-first design make it efficient for typical validation tasks, especially in the browser. At around 8kb gzipped, Zod is significantly smaller than Joi. It also works well with tree-shaking, so bundlers can remove unused parts. Using .safeParse()
avoids the overhead of try/catch and is often the better choice for expected validation failures.
import { z } from 'zod';
const schema = z.object({
/* schema definition */
});
function validateMany(items) {
return items.map(item => schema.safeParse(item));
}
In direct comparisons, Zod tends to be faster for simple validations, especially in browser environments where size and load time matter. Joi, on the other hand, performs better in complex validation scenarios with conditional logic or large datasets, thanks to schema compilation and caching.
While both libraries are fast enough for most apps, performance might become a deciding factor in high-throughput APIs or large-scale client-side apps. In those cases, it’s best to benchmark with your real data and validation needs. For everything else, features, ecosystem, and developer experience usually carry more weight in choosing between the two.
Final thoughts
Both Joi and Zod are capable validation libraries, but Zod stands out for modern TypeScript projects. Its ability to combine validation and type inference in a single schema reduces duplication and helps catch errors earlier. If you're building a full-stack TypeScript app or planning to adopt TypeScript, Zod is likely the better long-term fit.
Joi remains a strong choice, especially for JavaScript-first backends or legacy projects with complex validation needs. But for most new projects—especially those using TypeScript—Zod offers a cleaner, more efficient developer experience.
Make your mark
Join the writer's program
Are you a developer and love writing and sharing your knowledge with the world? Join our guest writing program and get paid for writing amazing technical guides. We'll get them to the right readers that will appreciate them.
Write for us
Build on top of Better Stack
Write a script, app or project on top of Better Stack and share it with the world. Make a public repository and share it with us at our email.
community@betterstack.comor submit a pull request and help us build better products for everyone.
See the full list of amazing projects on github