Валидация входящих запросов

Валидация входящих запросов в серверных приложениях является критическим этапом защиты и стабилизации системы. Любые данные, поступающие извне — тело запроса, параметры URL, query-строка, заголовки — должны рассматриваться как потенциально некорректные или злонамеренные. Ошибки на этом уровне приводят не только к сбоям, но и к уязвимостям: от нарушения бизнес-логики до инъекций и утечек данных.

Zod представляет собой декларативную библиотеку описания и проверки схем данных, ориентированную на строгую типизацию и предсказуемую валидацию. Основная идея заключается в том, что структура данных описывается один раз в виде схемы, после чего используется как для проверки входящих значений, так и для вывода типов TypeScript.


Любой HTTP-запрос в backend-системе состоит из нескольких независимых источников данных:

  • req.body — тело запроса (POST, PUT, PATCH)
  • req.params — параметры маршрута
  • req.query — query-строка
  • req.headers — заголовки

Каждый из этих источников имеет собственные особенности:

  • строки приходят даже там, где ожидаются числа или boolean
  • отсутствующие поля представлены как undefined
  • вложенные структуры могут быть частично сформированы
  • клиент может отправить лишние поля

Типовая ошибка заключается в предположении, что данные уже имеют нужный формат. В реальности требуется явное описание ожидаемой структуры.


Базовые схемы Zod

Основной строительный блок — схема (schema). Она описывает тип данных и правила его проверки.

import { z } fr om "zod";

Примитивные типы

const idSchema = z.string();
const ageSchema = z.number();
const isActiveSchema = z.boolean();

Каждая схема выполняет две функции:

  • проверяет значение
  • (в TypeScript) выводит соответствующий тип

Валидация тела запроса

Типичный сценарий — проверка req.body.

const createUserSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
  age: z.number().int().positive()
});

Применение:

const result = createUserSchema.safeParse(req.body);

if (!result.success) {
  return res.status(400).json(result.error.flatten());
}

const data = result.data;

Метод safeParse возвращает структурированный результат:

  • success: true → данные валидны
  • success: false → содержит объект ошибок

Использование safeParse предпочтительнее parse, поскольку исключения не прерывают поток выполнения.


Структура ошибок

Ошибки Zod имеют детализированную структуру:

{
  formErrors: [],
  fieldErrors: {
    email: ["Invalid email"],
    password: ["Too short"]
  }
}

Это позволяет напрямую использовать результат для фронтенда без дополнительной обработки.


Валидация параметров маршрута

Параметры URL всегда приходят в виде строк.

const paramsSchema = z.object({
  userId: z.string().uuid()
});

Использование:

const parsed = paramsSchema.safeParse(req.params);

При необходимости числового значения используется преобразование:

const paramsSchema = z.object({
  userId: z.string().transform(val => Number(val))
});

Валидация query-строки

Query-строка часто содержит опциональные и строковые значения:

const querySchema = z.object({
  page: z.string().optional(),
  lim it: z.string().optional()
});

Расширенный вариант с преобразованием типов:

const querySchema = z.object({
  page: z.string().transform(Number).default("1"),
  limit: z.string().transform(Number).default("20")
});

Преобразование данных (transform)

Одной из ключевых возможностей является трансформация значений после валидации:

const schema = z.string().transform(str => str.trim());

В контексте запросов:

const schema = z.object({
  tags: z.string().transform(val => val.split(","))
});

Это позволяет одновременно валидировать и нормализовать данные.


Приведение типов (coercion)

HTTP-запросы не несут строгих типов, поэтому часто используется coercion:

const schema = z.object({
  age: z.coerce.number()
});

Поведение:

  • "25"25
  • "abc" → ошибка

Это особенно важно для req.query и req.params.


Опциональные и дефолтные поля

const schema = z.object({
  name: z.string(),
  role: z.string().optional(),
  status: z.string().default("active")
});

Поведение:

  • optional() допускает отсутствие поля
  • default() подставляет значение при отсутствии

Глубокие структуры данных

Для сложных API используются вложенные объекты:

const schema = z.object({
  user: z.object({
    profile: z.object({
      firstName: z.string(),
      lastName: z.string()
    })
  })
});

Такая структура обеспечивает строгую типизацию даже для сложных JSON.


Массивы и коллекции

const schema = z.object({
  tags: z.array(z.string())
});

Валидация каждого элемента происходит автоматически.

Дополнительно:

z.array(z.number().int().positive()).min(1).max(10)

Пользовательские правила (refine)

Когда стандартных ограничений недостаточно:

const schema = z.string().refine(val => val.startsWith("usr_"), {
  message: "Invalid prefix"
});

Для объектов:

const schema = z.object({
  password: z.string(),
  confirmPassword: z.string()
}).refine(data => data.password === data.confirmPassword, {
  message: "Passwords do not match",
  path: ["confirmPassword"]
});

Асинхронная валидация

Некоторые проверки требуют обращения к базе данных или внешним сервисам:

const schema = z.string().refine(async (email) => {
  const user = await db.users.findByEmail(email);
  return !user;
}, {
  message: "Email already exists"
});

В этом случае используется parseAsync:

await schema.parseAsync(req.body);

Объединение схем

const baseSchema = z.object({
  email: z.string().email()
});

const extendedSchema = baseSchema.extend({
  password: z.string().min(8)
});

Также возможно объединение:

const schema = z.union([
  z.object({ type: z.literal("admin") }),
  z.object({ type: z.literal("user") })
]);

Partial и Pick

Для частичных обновлений:

const updateSchema = createUserSchema.partial();

Для выбора полей:

const schema = createUserSchema.pick({
  email: true
});

Валидаторы в middleware Express

const validate = (schema) => (req, res, next) => {
  const result = schema.safeParse(req.body);

  if (!result.success) {
    return res.status(400).json(result.error.flatten());
  }

  req.body = result.data;
  next();
};

Использование:

app.post("/users", validate(createUserSchema), handler);

Безопасность входных данных

Строгая валидация входящих запросов снижает поверхность атак:

  • предотвращение SQL-инъекций через типизацию
  • защита от неожиданных типов данных
  • контроль обязательных полей
  • ограничение диапазонов значений
  • нормализация входных данных до бизнес-логики

Особое значение имеет принцип: данные должны быть приведены к ожидаемой форме до попадания в бизнес-слой.


Паттерны проектирования валидации

Централизованные схемы

Схемы выносятся в отдельные модули:

/schemas
  user.schema.js
  auth.schema.js

Разделение слоёв

  • transport layer → валидация
  • service layer → бизнес-логика
  • repository layer → работа с БД

Декомпозиция схем

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

const userSchema = z.object({
  name: z.string(),
  address: addressSchema
});

Типизация из схем

Одно из ключевых преимуществ:

type User = z.infer<typeof userSchema>;

Это устраняет дублирование типов и схем.


Ограничения и строгий режим

const schema = z.object({
  name: z.string()
}).strict();

Поведение:

  • запрещает лишние поля
  • отклоняет неизвестные свойства

Это критично для публичных API.


Нормализация данных перед бизнес-логикой

Типичный pipeline обработки запроса:

  1. получение raw данных
  2. валидация схемой
  3. трансформация типов
  4. удаление лишних полей
  5. передача в сервис

Zod выступает центральным элементом на втором и третьем этапах, формируя гарантированно корректный контракт данных между клиентом и сервером.