Як перевірити дані запиту в Express.js?
Валідація запитів в Express.js - це перевірка вхідних даних за заданими правилами до того, як вони потраплять до обробника маршруту.
Теорія
TL;DR
- Схема - це список гостей,
req.body- це людина на вході. Не відповідає правилам - не проходить. - Головний підхід: описуєш схему (Zod, Joi або express-validator), обертаєш її в middleware, підключаєш до маршруту.
- TypeScript проект? Zod - він виводить типи автоматично. Звичайний JS? Joi. Треба швидко перевірити пару параметрів? express-validator.
- Валідація йде до будь-якої async операції. Перевіряти дані після запиту до БД - марна трата ресурсів.
- Всі три бібліотеки повертають структуровані помилки з деталями по кожному полю.
Швидкий приклад
const { z } = require('zod');
const express = require('express');
const app = express();
app.use(express.json());
const userSchema = z.object({
name: z.string().min(2),
email: z.string().email()
});
const validate = (schema) => (req, res, next) => {
try {
schema.parse(req.body);
next();
} catch (err) {
res.status(400).json({ errors: err.errors });
}
};
app.post('/users', validate(userSchema), (req, res) => {
res.json({ message: 'User created', data: req.body });
});
// { "name": "Bob", "email": "bob@example.com" } -> 200 OK
// { "name": "A", "email": "invalid" } -> 400 з помилками по поляхvalidate() - функція вищого порядку, що повертає middleware. Схема перевіряється до обробника. Якщо перевірка не пройшла - запит зупиняється тут, обробник маршруту не запускається.
Схеми проти ручних перевірок
Ручні if підходять для одного-двох полів. Далі вони розпадаються: ті самі перевірки копіюються по маршрутах, граничні випадки з приведенням типів упускаються, з'являються прогалини в безпеці. Схемні бібліотеки централізують правила, автоматично приводять типи і повертають структуровані повідомлення про помилки, які можна відправити клієнту без додаткової обробки.
Zod до цього виводить TypeScript-типи з схеми, тому req.body стає повністю типізованим нижче по ланцюжку без окремих інтерфейсів.
Яку бібліотеку обирати
- Zod: TypeScript проект, нова кодова база, потрібні виведені типи без дублювання.
- Joi: звичайний JavaScript, legacy-додаток або команда вже знайома з екосистемою Hapi. Трохи швидший за Zod при великому навантаженні.
- express-validator: мінімальне налаштування, треба перевірити кілька URL-параметрів чи query-рядків без повноцінної схемної бібліотеки.
- class-validator: NestJS або архітектура на основі класів, потрібен декораторний синтаксис на DTO.
Порівняння бібліотек
| Бібліотека | Підтримка TS | Формат помилок | Продуктивність | Коли використовувати |
|---|---|---|---|---|
| Zod | Нативна (виведені типи) | Детальні, з шляхом до поля | Висока | Нові TS проекти |
| Joi | Ручні типи | Детальні | Найвища | Plain JS / legacy |
| express-validator | Відсутня | Базові | Мінімальна залежність | Швидкі перевірки параметрів |
| class-validator | Декоратори | Налаштовувані | Повільніша (рефлексія) | NestJS / класи |
Як це працює всередині
express.json() розбирає тіло HTTP-запиту в req.body через Node.js http.IncomingMessage. Далі в стеку запускається validation middleware. Zod обходить об'єкт рекурсивно, перевіряє типи і обмеження по черзі. Якщо щось не збігається - кидає ZodError. Middleware перехоплює помилку і відповідає 400, обробник маршруту не запускається взагалі.
Одна деталь, яка постійно дивує: значення req.query завжди рядки. Запит ?page=2 дає рядок "2", а не число 2. Zod вирішує це через .transform(Number), але додавати треба явно - автоматично не відбувається.
Типові помилки
Помилка 1: валідація всередині обробника, після async-операцій.
// Неправильно: запит до БД виконується навіть при невалідних даних
app.post('/users', async (req, res) => {
await db.connect();
schema.parse(req.body); // запізно
});
// Правильно: middleware запускається першим
app.post('/users', validate(schema), async (req, res) => {
await db.connect();
});Помилка 2: забути express.json().
Без нього req.body буде undefined, і schema.parse(undefined) падає зі заплутаною помилкою замість чистого 400. Додавай перед маршрутами, одразу з обмеженням розміру:
app.use(express.json({ limit: '10kb' }));Помилка 3: використовувати .optional() на обов'язкових полях.
// Неправильно: пропускає порожні рядки та null
name: z.string().optional()
// Правильно: непорожній рядок
name: z.string().min(1)Це часте джерело помилок обмежень БД, які з'являються значно пізніше в ланцюжку запиту - далеко від місця реального баґу.
Помилка 4: не трансформувати числа з query-рядка.
// Неправильно: "abc" проходить z.string() але ламає логіку далі
query: z.object({ page: z.string() })
// Правильно: трансформація і перевірка числа
query: z.object({
page: z.string().transform(Number).refine(n => !isNaN(n) && n > 0, 'Має бути позитивним числом')
})Де використовується
- Next.js API routes: Zod на
/api/usersз виведеними типами обробника, стандарт у шаблонах Vercel. - NestJS: class-validator декоратори на DTO, стандарт у більшості enterprise-проектів.
- Strapi CMS: Joi-схеми для кастомних ендпоінтів плагінів.
- Звичайні Express API: express-validator для швидких перевірок параметрів, Zod для складного тіла запиту.
Питання на співбесіді
Q: Чому middleware краще за валідацію всередині обробника?
A: Middleware запускається до будь-якої бізнес-логіки, не витрачає ресурси на невалідні запити і дозволяє використовувати ту саму схему на кількох маршрутах. Добре компонується з auth-middleware в одному ланцюжку.
Q: Яка різниця між parse() і safeParse() в Zod?
A: parse() кидає ZodError при помилці - використовуй в try/catch всередині middleware. safeParse() повертає { success: boolean, data | error } і ніколи не кидає виняток - зручно для WebSocket-обробників або не-HTTP контекстів.
Q: Як валідувати завантаження файлів?
A: Спочатку розбираєш multipart-тіло через Multer, потім валідуєш req.body і req.file через Zod. Тип файлу перевіряй через z.enum(['image/jpeg', 'image/png']) на req.file.mimetype після того, як Multer відпрацював.
Q: Чи захищає Zod від prototype pollution?
A: Так, з версії 3.22 ключі __proto__ та подібні видаляються автоматично. Joi теж видаляє невідомі поля за замовчуванням. Якщо треба відхиляти будь-які непередбачені поля замість видалення - додавай .strict().
Q (senior): Ти будуєш API gateway для мікросервісів з 50+ схемами, що завантажуються динамічно по маршруту. Як проектуєш шар валідації?
A: Тримаєш центральний реєстр схем (Map in-process або Redis для розподілених систем) з ключем по route ID. Завантажуєш схеми ліниво і кешуєш скомпільовані Zod-об'єкти. Генератор OpenAPI-to-Zod тримає схеми в синхроні з API-контрактами. Для не-TS сервісів - Joi як запасний варіант. Версіонування через ключ реєстру: users:v1 і users:v2 співіснують без конфліктів.
Приклади
Базовий: валідація тіла запиту через safeParse
const { z } = require('zod');
const express = require('express');
const app = express();
app.use(express.json());
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(8)
});
const validate = (schema) => (req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ errors: result.error.errors });
}
req.body = result.data; // використовуємо провалідовані, приведені дані
next();
};
app.post('/login', validate(loginSchema), (req, res) => {
res.json({ message: 'Credentials valid' });
});
// { email: "a@b.com", password: "secret123" } -> 200
// { email: "notanemail", password: "short" } -> 400 з двома помилками по поляхsafeParse() зручніший за parse() у middleware - він ніколи не кидає виняток, помилка це просто значення, яке перевіряєш. Присвоєння result.data назад до req.body гарантує, що обробник отримає приведені дані, а не сирий ввід.
Середній рівень: тіло і query-параметри разом
const createUserSchema = z.object({
body: z.object({
name: z.string().min(2).max(50),
email: z.string().email(),
password: z.string().min(8).regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
'Пароль повинен містити великі, малі літери та цифру'
)
}),
query: z.object({
referral: z.string().optional()
})
});
const validate = (schema) => (req, res, next) => {
const result = schema.safeParse({ body: req.body, query: req.query });
if (!result.success) {
return res.status(400).json({
error: 'Validation failed',
details: result.error.errors.map(e => ({
field: e.path.join('.'),
message: e.message
}))
});
}
next();
};
app.post('/users', validate(createUserSchema), async (req, res) => {
res.json({ userId: 123 });
});
// POST /users?referral=abc { name:"Alice", email:"a@b.com", password:"Pass123" } -> 200
// POST /users { name:"A", email:"bad", password:"weak" } -> 400 з трьома помилкамиОдна схема охоплює і body, і query - один middleware-виклик на маршрут. Кожна помилка у відповіді містить повний шлях до поля, наприклад body.password, тому клієнт одразу розуміє що пішло не так.
Просунутий рівень: вкладені масиви з трансформацією
Цей шаблон часто збиває з пантелику - .transform() в Zod змінює форму виводу, і req.body в обробнику виглядає інакше ніж вхідні дані.
const orderSchema = z.object({
items: z.array(z.object({
productId: z.string().uuid(),
quantity: z.number().int().positive().max(99),
metadata: z.record(z.any()).optional() // дозволяє довільні поля без помилки
}))
.min(1)
.max(10)
}).transform(data => ({
...data,
totalItems: data.items.reduce((sum, item) => sum + item.quantity, 0)
}));
const validate = (schema) => (req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ errors: result.error.errors });
}
req.body = result.data; // містить totalItems, обчислений Zod
next();
};
app.post('/orders', validate(orderSchema), (req, res) => {
console.log(req.body.totalItems); // вже обчислено, не треба рахувати знову
res.json({ order: req.body });
});
// { items: [{ productId: "550e8400-e29b-41d4-a716-446655440001", quantity: 5 }] }
// -> 200, totalItems: 5
// { items: [] }
// -> 400 "Array must contain at least 1 item(s)"
// { items: [{ productId: "not-uuid", quantity: 0 }] }
// -> 400 дві окремі помилки.transform() запускається лише після того, як усі перевірки пройшли. В req.body потрапляє чистий, збагачений об'єкт - рахувати totalItems в обробнику більше не треба. Zod 3.22+ також видаляє __proto__ та подібні ключі автоматично, тому захист від prototype pollution вже вбудований.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.