Миграция с Joi

Библиотеки валидации данных в 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 результата валидации.

Union-типы и альтернативы

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 и null
  • Перенос кастомной логики без учёта ctx в superRefine

Инкрементальный переход схем

Миграция часто выполняется поэтапно. Сначала заменяются простые схемы без кастомной логики, затем union-типы и вложенные структуры, после чего переносится сложная валидация и трансформации.

Промежуточные слои могут сосуществовать: Joi продолжает обслуживать часть схем, Zod — новые модули. Это снижает риск регрессий в больших кодовых базах.

Сравнение поведения на уровне исполнения

Joi возвращает нормализованный объект результата, включая преобразования и дефолты в рамках validate(). Zod всегда возвращает строго типизированный результат после parse, обеспечивая консистентность между runtime и compile-time моделями.

Различие в философии приводит к тому, что Zod требует более явного описания всех преобразований, тогда как Joi допускает скрытые модификации через опции схем.