Библиотеки валидации данных в JavaScript решают схожие задачи, но различаются философией и подходом к описанию схем. Joi ориентирован на декларативное описание схем с богатым API и множеством встроенных методов, тогда как Zod строится вокруг композиционного подхода, тесно интегрированного с TypeScript и строгой типизацией на уровне схем.
Ключевое различие заключается в модели типов: Joi опирается на runtime-валидацию без тесной связи с TypeScript-типами, тогда как Zod генерирует типы напрямую из схемы, устраняя необходимость в отдельном описании интерфейсов.
В Joi схема описывается через цепочку методов:
const schema = Joi.object({
name: Joi.string().min(2).required(),
age: Joi.number().integer().min(0)
});
В Zod аналогичная схема выражается через композицию:
import { z } from "zod";
const schema = z.object({
name: z.string().min(2),
age: z.number().int().min(0).optional()
});
Отсутствие .required() в Zod связано с другим дефолтным
поведением: поля считаются обязательными по умолчанию, а опциональность
задаётся явно через .optional().
Одним из центральных аспектов миграции становится переход от ручного описания типов к автоматическому выводу:
type User = z.infer<typeof schema>;
В Joi эквивалент требует отдельного интерфейса:
interface User {
name: string;
age?: number;
}
При миграции это приводит к устранению дублирования определения структуры данных, однако требует внимательного контроля сложных преобразований типов, особенно при наличии union-структур и кастомных валидаторов.
Joi активно использует .keys():
const schema = Joi.object().keys({
email: Joi.string().email(),
password: Joi.string().min(8)
});
В Zod структура задаётся напрямую через z.object без
дополнительного метода:
const schema = z.object({
email: z.string().email(),
password: z.string().min(8)
});
При миграции упрощается дерево вызовов и снижается количество промежуточных абстракций.
В Joi поведение дефолтов задаётся через .default():
const schema = Joi.object({
role: Joi.string().default("user")
});
В Zod аналог реализуется следующим образом:
const schema = z.object({
role: z.string().default("user")
});
Различие проявляется при трансформации результата: Zod всегда
возвращает значение с применённым .parse(), тогда как Joi
требует обращения к value результата валидации.
Joi использует Joi.alternatives():
const schema = Joi.alternatives().try(
Joi.string(),
Joi.number()
);
Zod выражает это через z.union:
const schema = z.union([z.string(), z.number()]);
При миграции упрощается структура описания, особенно в сложных вложенных схемах.
В Joi кастомная логика задаётся через .custom():
const schema = Joi.string().custom((value, helpers) => {
if (value !== "allowed") {
return helpers.error("any.invalid");
}
return value;
});
В Zod используется .refine() или
.superRefine():
const schema = z.string().refine((value) => value === "allowed", {
message: "Invalid value"
});
или расширенный вариант:
const schema = z.string().superRefine((value, ctx) => {
if (value !== "allowed") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Invalid value"
});
}
});
Joi поддерживает асинхронные проверки через
validateAsync:
await schema.validateAsync(data);
Zod использует parseAsync:
await schema.parseAsync(data);
При миграции важно учитывать, что синхронный и асинхронный API в Zod разделены строго, тогда как Joi допускает более гибкое переключение режимов.
Joi возвращает объект с error и value:
const { error, value } = schema.validate(data);
Zod при невалидных данных выбрасывает исключение:
try {
schema.parse(data);
} catch (e) {
// ZodError
}
Альтернативный вариант без исключений:
const result = schema.safeParse(data);
Миграция требует перехода от проверки error к работе с
исключениями или результатами safeParse.
Joi активно использует concat:
const base = Joi.object({ a: Joi.string() });
const extended = base.concat(Joi.object({ b: Joi.number() }));
В Zod композиция достигается через .extend():
const base = z.object({ a: z.string() });
const extended = base.extend({ b: z.number() });
Такой подход делает расширение схем более линейным и предсказуемым.
В Joi часто применяются фабрики схем:
const createSchema = () =>
Joi.object({
id: Joi.string().uuid()
});
В Zod схемы являются первоклассными значениями:
const schema = z.object({
id: z.string().uuid()
});
Переиспользование достигается через композицию и
z.infer, что снижает необходимость в дополнительных
фабричных функциях.
Joi использует .alter() и .custom() для
преобразований. В Zod применяется .transform():
const schema = z.string().transform((val) => val.trim());
При миграции упрощается цепочка преобразований, так как трансформации встроены в основной pipeline схемы.
Joi:
const schema = Joi.object({
user: Joi.object({
name: Joi.string()
})
});
Zod:
const schema = z.object({
user: z.object({
name: z.string()
})
});
Поведение идентично, однако Zod позволяет легче извлекать типы
вложенных объектов через z.infer.
.required() логики при переходе к Zod, где
обязательность является дефолтной.nullable() и
.optional()validate() на parse() без обработки
исключенийundefined и
nullctx в
superRefineМиграция часто выполняется поэтапно. Сначала заменяются простые схемы без кастомной логики, затем union-типы и вложенные структуры, после чего переносится сложная валидация и трансформации.
Промежуточные слои могут сосуществовать: Joi продолжает обслуживать часть схем, Zod — новые модули. Это снижает риск регрессий в больших кодовых базах.
Joi возвращает нормализованный объект результата, включая
преобразования и дефолты в рамках validate(). Zod всегда
возвращает строго типизированный результат после parse,
обеспечивая консистентность между runtime и compile-time моделями.
Различие в философии приводит к тому, что Zod требует более явного описания всех преобразований, тогда как Joi допускает скрытые модификации через опции схем.