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

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

Дочитали статтю?