Валидация данных в Node-сервисе: ошибки и как их избежать
Валидация «на всякий случай» в контроллере, проверка после парсинга, доверие к клиенту — самые дорогие ошибки в Node-сервисах. Разбираем, где ломается защита и как выстроить границу данных правильно.

Пользователь отправил {"age": "20"} вместо числа, сервис записал строку в базу, а через месяц отчёт по возрастам упал на NaN при суммировании. Классика: валидации формально не было, потому что «там же всё простое». В Node-сервисе на Express или Fastify граница между внешними данными и вашей логикой — самое дырявое место, и большинство инцидентов с данными родом отсюда. Разберём типичные ошибки валидации на входе и как их закрыть, на примерах с Zod 3.x и Express 4.x.
Речь только про валидацию входящих данных HTTP-слоя: тело запроса, query, params, заголовки. Валидацию бизнес-инвариантов на уровне доменной модели и проверки в базе оставим за скобками — это отдельная тема.
Где валидация ломается чаще всего
Проблема почти никогда не в том, что валидации нет совсем. Она есть, но стоит не там, проверяет не то или доверяет тому, чему доверять нельзя.
Ошибка 1. Валидация размазана по контроллеру
Самый частый антипаттерн — проверки живут вперемешку с логикой:
// плохо: проверки и логика в одной куче
app.post('/users', async (req, res) => {
const { email, age } = req.body;
if (!email) return res.status(400).json({ error: 'email required' });
if (typeof age !== 'number') return res.status(400).json({ error: 'age must be number' });
if (age < 0 || age > 150) return res.status(400).json({ error: 'age out of range' });
// ...ещё двадцать строк проверок, потом наконец бизнес-логика
const user = await createUser({ email, age });
res.json(user);
});
Через полгода таких проверок будет тридцать, часть продублируется в соседних эндпоинтах, а часть — разойдётся: в одном месте age ограничен 150, в другом забыли. Контроллер должен получать уже провалидированный и типизированный объект, а не разбирать сырьё.
Ошибка 2. Проверка после того, как данные уже использованы
Данные читают из req.body, что-то с ними делают, и только потом валидируют. К моменту проверки в базу уже ушёл запрос или логи заполнились мусором. Валидация должна стоять до любого использования, как самый первый шаг обработки запроса — в идеале в middleware.
Ошибка 3. Доверие к Content-Type и структуре
Код предполагает, что req.body — это объект с нужными полями. Но клиент может прислать массив, строку, null или вложенный объект другой формы. Обращение req.body.email к массиву вернёт undefined молча, а req.body.user.name к отсутствующему user уронит запрос с TypeError. Схема должна проверять сам факт того, что на входе объект ожидаемой формы, а не только значения полей.
Ошибка 4. Валидация без коэрции и нормализации
Query-параметры всегда приходят строками: ?age=20&active=true — это { age: '20', active: 'true' }. Если схема ждёт число, а вы передаёте строку без приведения, либо валидация падает, либо в логику уходит '20', и дальше начинается зоопарк из parseInt по всему коду.
Ошибка 5. Возврат внутренних ошибок наружу
Библиотека выбрасывает подробную ошибку с путями, ожидаемыми типами и иногда — фрагментами данных. Отдавать это клиенту как есть — и утечка структуры, и нестабильный контракт. Клиенту нужен предсказуемый формат ошибок, а стектрейс — в логи.
Как выстроить границу правильно
Идея одна: у сервиса есть внешняя граница, на которой любые данные превращаются в типизированные и проверенные. За границей код работает с чистыми данными и больше их не перепроверяет.
Схема отдельно, middleware отдельно
Опишем схему один раз и подключим через переиспользуемую middleware. Пример на Zod 3.x и Express 4.x:
const { z } = require('zod');
// схема входа: коэрция строк, границы, дефолты
const createUserSchema = z.object({
email: z.string().email(),
age: z.coerce.number().int().min(0).max(150),
role: z.enum(['user', 'admin']).default('user'),
});
// универсальная middleware: валидирует нужную часть запроса
function validate(schema, source = 'body') {
return (req, res, next) => {
const result = schema.safeParse(req[source]);
if (!result.success) {
// наружу — только читаемые сообщения, без внутренностей
return res.status(400).json({
error: 'validation_failed',
details: result.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
});
}
// кладём разобранные данные отдельно от сырых
req.validated = { ...(req.validated || {}), [source]: result.data };
next();
};
}
app.post('/users', validate(createUserSchema), async (req, res) => {
// здесь req.validated.body уже типизирован и проверен
const user = await createUser(req.validated.body);
res.status(201).json(user);
});
Ключевой момент — результат кладётся в req.validated, а не поверх req.body. Так видно, где сырые данные, а где проверенные, и случайное чтение req.body в обход валидации сразу заметно на ревью.
Коэрция там, где источник — строки
z.coerce.number() превратит '20' в 20, но будьте аккуратны с булевыми: z.coerce.boolean() считает истиной любую непустую строку, поэтому 'false' станет true. Для флагов из query безопаснее явное сопоставление:
// 'true' -> true, всё остальное -> false
const boolFromQuery = z
.enum(['true', 'false'])
.transform((value) => value === 'true');
Валидация — это не забор из проверок в начале функции. Это одна граница, за которой данным можно доверять. Если проверку приходится повторять глубже — граница проведена не там.
Strict против strip
По умолчанию Zod отбрасывает лишние поля (strip). Иногда это удобно, иногда опасно: клиент прислал isAdmin: true, поле молча выкинулось, и никто не заметил попытки. Если хотите отклонять запросы с неожиданными полями — используйте .strict() на объекте. Выбор зависит от контракта, но решение должно быть осознанным, а не по умолчанию.
Что выбрать: сравнение подходов
Библиотек несколько, и разница не только в синтаксисе. Ориентиры на конец 2024 года — проверяйте актуальные версии перед выбором.
| Подход | Выводит TypeScript-типы | Коэрция строк | Когда брать |
|---|---|---|---|
| Zod | Да, из схемы | Да, z.coerce | TypeScript-проект, нужны типы из одного источника |
| Joi | Нет, типы пишутся отдельно | Да, convert: true | JS-проект, привычка к Joi, сложные условные схемы |
| Ajv (JSON Schema) | Частично, через генерацию | Через опции | Нужна стандартная JSON Schema, максимум скорости |
| Ручные проверки | Нет | Руками | Один-два поля, не растёт — но это редкость |
Общее правило: если проект на TypeScript, схема-источник типов (Zod и аналоги) убирает рассинхрон между тем, что проверяется, и тем, что описано в типах. Ajv выигрывает на пропускной способности и когда схема нужна в формате JSON Schema для внешних потребителей.
Отдельные грабли, о которых забывают
- Лимит размера тела. Валидация не спасёт от 50-мегабайтного JSON, который сначала полностью прочитается в память. Ограничьте размер на уровне парсера:
express.json({ limit: '100kb' }). - Вложенные объекты и массивы. Проверять форму верхнего уровня мало. Массив объектов без ограничения длины — способ уронить сервис большим payload; задавайте
.max()на массивах. - Числа за пределами безопасного диапазона. JSON-число больше
Number.MAX_SAFE_INTEGERпотеряет точность ещё при парсинге, до валидации. Для идентификаторов из внешних систем принимайте строку и проверяйте формат. - Пустая строка против отсутствия.
''иundefined— разные случаи. Решите явно:.min(1)запретит пустую строку,.optional()разрешит отсутствие поля. - Порядок middleware. Парсер тела (
express.json()) должен стоять до валидации, иначеreq.bodyбудетundefined, и схема упадёт на ровном месте.
Единый формат ошибок
Раскидывать res.status(400) по контроллерам — тот же антипаттерн, что и раскиданная валидация. Заведите один error-handler и единый JSON-контракт ошибки: код, человекочитаемое сообщение, список полей. Клиенту достаточно знать структуру, а не реализацию. Внутренние подробности — путь ошибки, стектрейс — пишите в лог с идентификатором запроса, чтобы соотнести жалобу клиента с записью.
Что сделать дальше
Пройдитесь по эндпоинтам и проверьте три вещи: у каждого ли входа есть схема, стоит ли валидация первым шагом до логики, читает ли контроллер req.validated, а не сырой req.body. Дальше — вынесите общие фрагменты схем (пагинация, идентификаторы) в переиспользуемые куски, чтобы ?page= и ?limit= проверялись одинаково везде. И добавьте лимит размера тела на уровне парсера, если его ещё нет — это одна строка, которая закрывает целый класс проблем.
Инфобез и 152-ФЗ: Аудит политик конфиденциальности, правила сбора cookie.
Спросить за 20 ₽Источники: Zod — официальная документация, Express — Error handling
Частые вопросы
У меня Zod 4, а не 3 — примеры сработают?
А если валидация не срабатывает — запрос проходит с мусором?
Нужно ли валидировать данные, которые пришли из моей же базы?
Zod заметно медленнее Ajv — это проблема в проде?
Стоит ли отклонять запросы с лишними полями через .strict()?
Можно ли использовать одну схему и для валидации, и для типов?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.