Skip to main content

Як обробляти помилки в Express.js?

Обробка помилок в Express.js - механізм, який перехоплює помилки з обробників маршрутів і передає їх спеціальному error middleware, що формує відповідь замість того, щоб крашити процес.

Теорія

TL;DR

  • Синхронні помилки в маршрутах Express перехоплює автоматично
  • Async помилки (promise, await) НЕ перехоплюються автоматично - потрібен try/catch і next(err)
  • Error middleware має рівно 4 параметри (err, req, res, next) і реєструється останнім
  • Обгортка asyncHandler позбавляє від повторення try/catch у кожному маршруті
  • В Node 15+ необроблений rejection крашить процес

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

js
// НЕПРАВИЛЬНО: async помилка обходить error handling Express app.get('/user/:id', async (req, res) => { const user = await User.findById(req.params.id); // rejection не перехоплено res.json(user); }); // ПРАВИЛЬНО: перехоплюємо і передаємо в error middleware app.get('/user/:id', async (req, res, next) => { try { const user = await User.findById(req.params.id); res.json(user); } catch (err) { next(err); // направляє в error-handling middleware } }); // Error middleware - 4 параметри, реєструється останнім app.use((err, req, res, next) => { res.status(err.status || 500).json({ error: err.message }); });

Express бачить 4-параметровий підпис і розуміє, що це обробник помилок, а не звичайний middleware.

Синхронні vs асинхронні помилки

Express загортає синхронні обробники маршрутів у внутрішній блок try/catch. Коли ти throw-иш у синхронному обробнику, Express перехоплює помилку і передає її далі по ланцюгу error middleware.

З async обробниками інша картина. Коли викликається async функція, вона одразу повертає Promise. Express викликає обробник, отримує Promise і рухається далі. Якщо той Promise відхиляється пізніше, Express вже "пішов" від цього обробника - rejection відбувається поза межами його try/catch. Node.js генерує подію unhandledRejection, і в Node 15+ це завершення процесу.

Саме тут найчастіше виникають непомітні збої в Express-застосунках. Один пропущений try/catch у популярному маршруті - і сервер падає.

Коли що використовувати

  • Синхронний код у маршруті - throw freely, Express перехоплює сам
  • async/await у маршруті - обгорни в try/catch, у catch блоці виклич next(err)
  • Callbacks (старий код) - виклич next(err) в error-шляху callback
  • Багато async маршрутів - використовуй обгортку asyncHandler, щоб не писати try/catch скрізь
  • Неопрацьовані маршрути (404) - додай catch-all middleware після всіх маршрутів, перед error handler

Патерн asyncHandler

Писати try/catch у кожному маршруті швидко набридає. Обгортка asyncHandler вирішує це:

js
// Обгортка перехоплює rejected promises і передає в next() const asyncHandler = (fn) => (req, res, next) => { Promise.resolve(fn(req, res, next)).catch(next); }; // try/catch у маршруті більше не потрібен app.get('/users/:id', asyncHandler(async (req, res) => { const user = await User.findById(req.params.id); if (!user) { const err = new Error('Not found'); err.status = 404; throw err; // обгортка перехопить і викличе next(err) } res.json(user); }));

Обгортка резолвить повернутий Promise і, якщо він відхиляється, передає помилку напряму в next. Саме цей патерн використовують бібліотеки express-async-errors та express-async-handler на npm.

Власні класи помилок

Для простих випадків достатньо додати status до звичайного Error. Але власні класи помилок роблять обробку структурованішою при зростанні застосунку:

js
class AppError extends Error { constructor(message, statusCode) { super(message); this.statusCode = statusCode; this.isOperational = true; // прапор для "очікуваних" помилок Error.captureStackTrace(this, this.constructor); } } class NotFoundError extends AppError { constructor(resource = 'Resource') { super(`${resource} not found`, 404); } } class ValidationError extends AppError { constructor(message) { super(message, 400); } }

В error middleware перевіряй err instanceof NotFoundError, щоб по-різному обробляти різні типи помилок. Операційні помилки (поганий input, відсутній ресурс) несуть власні статус-коди. Несподівані помилки отримують 500.

Як Express розпізнає error middleware

Express визначає, чи є middleware обробником помилок, перевіряючи властивість .length функції - кількість оголошених параметрів. Звичайний middleware має 2 або 3 параметри. Error middleware - рівно 4. Напишеш (err, req, res) - 3 параметри - Express сприйме це як звичайний middleware і пропустить для помилок.

Тому параметр next в error middleware потрібно завжди оголошувати, навіть якщо ніколи не викликаєш його.

Типові помилки

Помилка 1: async обробник без try/catch

js
// НЕПРАВИЛЬНО: rejection не перехоплено, Node 15+ завершить процес app.get('/data', async (req, res) => { const data = await fetchData(); res.json(data); }); // ПРАВИЛЬНО app.get('/data', async (req, res, next) => { try { const data = await fetchData(); res.json(data); } catch (err) { next(err); } });

Express загортає тільки синхронну частину обробника. Async частина виконується після того, як обробник вже повернув значення.

Помилка 2: error middleware зареєстровано перед маршрутами

js
// НЕПРАВИЛЬНО: обробник помилок реєструється першим - маршрути його не досягнуть app.use((err, req, res, next) => { res.status(500).json({ error: err.message }); }); app.get('/users', async (req, res, next) => { /* ... */ }); // ПРАВИЛЬНО: спочатку маршрути, потім error handler app.get('/users', async (req, res, next) => { /* ... */ }); app.use((err, req, res, next) => { res.status(500).json({ error: err.message }); });

Express обробляє middleware в порядку реєстрації. Error handler, зареєстрований до маршрутів, ніколи не буде в ланцюзі виконання цих маршрутів.

Помилка 3: виклик next() до async операції

js
// НЕПРАВИЛЬНО app.get('/user/:id', async (req, res, next) => { next(); // каже Express "тут закінчив" const user = await User.findById(req.params.id); // помилка тут - без обробника res.json(user); });

Як тільки next() викликано без аргументу помилки, Express переходить до наступного middleware. Будь-яка помилка після цього не має обробника.

Помилка 4: одночасна відправка відповіді і передача помилки

js
// НЕПРАВИЛЬНО: і res.json(), і next(err) можуть виконатись одночасно app.get('/user/:id', async (req, res, next) => { const user = await User.findById(req.params.id).catch(next); res.json(user); // виконається навіть якщо .catch(next) вже спрацював }); // ПРАВИЛЬНО: або відправляємо відповідь, або передаємо помилку app.get('/user/:id', async (req, res, next) => { try { const user = await User.findById(req.params.id); if (!user) throw new NotFoundError('User'); res.json(user); } catch (err) { next(err); } });

Після виклику res.json() або res.send() відповідь відправлено. Повторний виклик дає "Cannot set headers after they are sent".

Помилка 5: однакова обробка всіх помилок

js
// НЕПРАВИЛЬНО: все стає 500 app.use((err, req, res, next) => { res.status(500).json({ error: err.message }); }); // ПРАВИЛЬНО: беремо статус з об'єкта помилки app.use((err, req, res, next) => { const status = err.statusCode || err.status || 500; const message = err.message || 'Internal server error'; if (!err.isOperational) { console.error('Unexpected error:', err); // логуємо програмні помилки } res.status(status).json({ error: { status, message }, ...(process.env.NODE_ENV === 'development' && { stack: err.stack }) }); });

Операційні помилки (NotFoundError, ValidationError) несуть власні статус-коди і є очікуваними. Програмні помилки потрібно логувати і повертати 500. Однакова обробка ховає реальні баги.

Де зустрічається в реальних проектах

  • Express-застосунки - async маршрути потребують try/catch + next(err) або обгортки asyncHandler
  • express-async-errors - npm пакет, який патчить Express для автоматичної обробки async помилок
  • NestJS - використовує exception filters замість middleware, та сама ідея з іншим синтаксисом
  • Fastify - вбудована обробка async помилок, обгортка не потрібна
  • Koa - використовує try/catch прямо в middleware, без патерну next(err)

Follow-up питання

Q: Чому Express не перехоплює помилки в async обробниках автоматично?
A: Express загортає синхронне виконання обробника в try/catch. Async функція одразу повертає Promise, тому до моменту, коли Promise відхиляється, обробник вже повернув значення і try/catch закрився. Rejection відбувається поза областю видимості Express.

Q: Що відбувається з необробленими promise rejection у Node 15+?
A: Node.js завершує процес. До Node 15 це видавало лише deprecation попередження. Тому залишати async помилки без обробки в Express - це не просто погана практика, це краш продакшену.

Q: Чи можна мати кілька error-handling middleware функцій?
A: Так. Express викликає їх по порядку, поки одна не припинить викликати next(err). Можна мати спеціалізовані обробники для validation і database помилок перед загальним catch-all. Кожен має оголошувати рівно 4 параметри.

Q: Що відбувається, якщо error middleware сам кидає помилку?
A: Express перехоплює її і відправляє загальну відповідь 500. Error middleware має бути захищеним: перевіряй властивості err, використовуй значення за замовчуванням, не викликай зовнішній код без захисту.

Q: Як організувати обробку помилок у великому застосунку з різними доменами (auth, users, payments)?
A: Створи власні класи помилок для кожного домену - AuthenticationError, PaymentError, ValidationError - кожен з властивістю statusCode. В маршрутах throw потрібний клас. У глобальному error handler перевіряй instanceof для різного форматування: AuthenticationError повертає 401 без внутрішніх деталей, ValidationError - 400 з описом полів, несподівані помилки - 500 і запис у лог. Це відокремлює логіку форматування від бізнес-логіки і робить кожен error path тестованим.

Приклади

Базовий: синхронна vs асинхронна обробка помилок

js
const express = require('express'); const app = express(); // Sync: Express перехоплює автоматично app.get('/sync', (req, res) => { throw new Error('Something went wrong'); // try/catch не потрібен }); // Async: потрібно перехопити самостійно і передати в next app.get('/async', async (req, res, next) => { try { const data = await fetchSomething(); res.json(data); } catch (err) { next(err); // передаємо в error middleware } }); // Error middleware - завжди останній, завжди 4 параметри app.use((err, req, res, next) => { res.status(err.status || 500).json({ error: err.message }); }); app.listen(3000);

Синхронний throw одразу потрапляє в error middleware. Async помилка без try/catch крашить процес в Node 15+.

Середній: продакшн маршрут з валідацією і помилками бази даних

js
app.post('/users', async (req, res, next) => { try { // операційна помилка - поганий input від клієнта if (!req.body.email) { const err = new Error('Email is required'); err.status = 400; throw err; } const user = await User.create(req.body); res.status(201).json(user); } catch (err) { next(err); // всі помилки йдуть в error handler } }); app.use((err, req, res, next) => { const status = err.status || 500; const message = err.message || 'Internal server error'; res.status(status).json({ error: { status, message }, // stack видно лише в розробці ...(process.env.NODE_ENV === 'development' && { stack: err.stack }) }); });

Помилки валідації повертають 400. Несподівані помилки бази даних - 500. Stack trace видно лише в режимі розробки.

Просунутий: повне налаштування з власними класами помилок і asyncHandler

js
// Власні класи помилок class AppError extends Error { constructor(message, statusCode) { super(message); this.statusCode = statusCode; this.isOperational = true; Error.captureStackTrace(this, this.constructor); } } class NotFoundError extends AppError { constructor(resource = 'Resource') { super(`${resource} not found`, 404); } } class ValidationError extends AppError { constructor(message) { super(message, 400); } } // Обгортка asyncHandler - без try/catch у маршрутах const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); // Маршрут без boilerplate app.get('/users/:id', asyncHandler(async (req, res) => { const user = await User.findById(req.params.id); if (!user) throw new NotFoundError('User'); // обгортка передасть в next() res.json(user); })); // 404 для неопрацьованих маршрутів - після всіх маршрутів app.use((req, res, next) => { next(new NotFoundError(`Route ${req.method} ${req.path}`)); }); // Глобальний error handler - завжди останній app.use((err, req, res, next) => { const status = err.statusCode || err.status || 500; if (!err.isOperational) { console.error('Unexpected error:', err); // логуємо несподівані помилки } res.status(status).json({ error: err.message, ...(process.env.NODE_ENV !== 'production' && { stack: err.stack }) }); });

isOperational позначає помилки, які ти очікуєш (поганий input, відсутні записи). Несподівані помилки логуються з повним stack trace перед тим, як клієнту йде загальний 500.

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

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

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

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