Валидация данных: Joi, Zod

Joi и Zod — JavaScript-библиотеки схем для декларативной валидации данных на сервере (и клиенте), задающие правила через цепочки методов или типы TypeScript.

Зачем нужно

HTML5-валидация и браузерная проверка форм — первый рубеж, но обойти их легко (прямой POST-запрос). Серверная валидация через Joi или Zod — обязательный второй рубеж: все данные от пользователя считаются ненадёжными. Zod добавляет TypeScript-типы из схемы автоматически, что устраняет дублирование типов и интерфейсов. Joi — более зрелый вариант для Node.js-бэкенда.

Где используется

  • Серверная валидация API-запросов (Express, Fastify, NestJS)
  • Валидация переменных окружения (.env проверка при старте)
  • Zod: вместе с React Hook Form, tRPC, Next.js App Router
  • Joi: в Hapi.js (встроена), Express middleware
  • Парсинг и типизация ответов от внешних API

Zod — схемы с TypeScript-типами

import { z } from 'zod';

// Определение схемы
const registerSchema = z.object({
  name: z.string.min(2, 'Имя не менее 2 символов').max(50),
  email: z.string.email('Некорректный email'),
  password: z.string.min(8, 'Пароль не менее 8 символов'),
  age: z.number.int.min(18, 'Только для взрослых').optional,
  role: z.enum(['user', 'admin']).default('user'),
});

// Автоматический TypeScript-тип
type RegisterInput = z.infer<typeof registerSchema>;

// Парсинг (throw при ошибке)
const data = registerSchema.parse(req.body);

// Безопасный парсинг (без throw)
const result = registerSchema.safeParse(req.body);
if (!result.success) {
  const errors = result.error.flatten.fieldErrors;
  return res.status(400).json({ errors });
}
const validData = result.data;

Zod — вложенные схемы и массивы

const addressSchema = z.object({
  city: z.string,
  street: z.string,
});

const orderSchema = z.object({
  items: z.array(z.object({
    productId: z.string.uuid,
    quantity: z.number.int.positive,
  })).min(1, 'Корзина пустая'),
  address: addressSchema,
  delivery: z.enum(['courier', 'pickup']),
});

Joi — схемы для Node.js

const Joi = require('joi');

const registerSchema = Joi.object({
  name: Joi.string.min(2).max(50).required,
  email: Joi.string.email.required,
  password: Joi.string.min(8).required,
  age: Joi.number.integer.min(18).optional,
  role: Joi.string.valid('user', 'admin').default('user'),
});

// Валидация
const { error, value } = registerSchema.validate(req.body, {
  abortEarly: false, // собрать все ошибки, не только первую
});

if (error) {
  const messages = error.details.map(d => d.message);
  return res.status(400).json({ errors: messages });
}
// value — очищенные и приведённые данные

Express middleware с Joi

function validate(schema) {
  return (req, res, next) => {
    const { error, value } = schema.validate(req.body, { abortEarly: false });
    if (error) {
      return res.status(400).json({
        errors: error.details.map(d => ({ field: d.path[0], message: d.message }))
      });
    }
    req.body = value; // заменить тело очищенными данными
    next;
  };
}

app.post('/register', validate(registerSchema), registerController);

Сравнение Zod и Joi

Критерий Zod Joi
TypeScript-интеграция Встроена (типы из схемы) Базовая (отдельные типы)
Размер бандла Меньше (~13 кБ) Больше (~30 кБ)
Экосистема tRPC, React Hook Form, Prisma Hapi, Express, NestJS
API Цепочки TypeScript Цепочки JS
Кастомные валидаторы .refine, .superRefine .custom

Частые ошибки

Ошибка Почему плохо Как правильно
Только HTML5-валидация без серверной Обходится прямым запросом Всегда валидируй на сервере
abortEarly: true (по умолчанию в Joi) Пользователь видит только первую ошибку abortEarly: false
Не очищать входные данные XSS, инъекции Используй value из Joi или data из Zod
Дублировать Zod-схему и TypeScript-тип Рассинхрон type T = z.infer<typeof schema>

Связанные темы

Ресурсы