Skip to main content

Як перевірити дані запиту в Express.js?

Валідація запитів в Express.js - це перевірка вхідних даних за заданими правилами до того, як вони потраплять до обробника маршруту.

Теорія

TL;DR

  • Схема - це список гостей, req.body - це людина на вході. Не відповідає правилам - не проходить.
  • Головний підхід: описуєш схему (Zod, Joi або express-validator), обертаєш її в middleware, підключаєш до маршруту.
  • TypeScript проект? Zod - він виводить типи автоматично. Звичайний JS? Joi. Треба швидко перевірити пару параметрів? express-validator.
  • Валідація йде до будь-якої async операції. Перевіряти дані після запиту до БД - марна трата ресурсів.
  • Всі три бібліотеки повертають структуровані помилки з деталями по кожному полю.

Швидкий приклад

js
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-операцій.

js
// Неправильно: запит до БД виконується навіть при невалідних даних 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. Додавай перед маршрутами, одразу з обмеженням розміру:

js
app.use(express.json({ limit: '10kb' }));

Помилка 3: використовувати .optional() на обов'язкових полях.

js
// Неправильно: пропускає порожні рядки та null name: z.string().optional() // Правильно: непорожній рядок name: z.string().min(1)

Це часте джерело помилок обмежень БД, які з'являються значно пізніше в ланцюжку запиту - далеко від місця реального баґу.

Помилка 4: не трансформувати числа з query-рядка.

js
// Неправильно: "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

js
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-параметри разом

js
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 в обробнику виглядає інакше ніж вхідні дані.

js
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 вже вбудований.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.