Node.js and Express: Error Handling


Error handling in Express is managed by a special type of middleware specifically designed to catch and process errors that occur during request processing. The main characteristics of the error handler middleware are:
The middleware is distinguished by its four-parameter signature:
err
,req
,res
, andnext
.It must be defined last, after all other middlewares and routes.
The middleware is called when something throws an error (synchronously or asynchronously) or
next
is called with an argument.
With this brief introduction, let's implement it in our ongoing project, which you can download from here.
Error Handling Middleware
Install the following package by running this command:
npm install http-problem-details
This package will help us follow RFC 9457, Problem Details for HTTP APIs, which is a specification that defines a standard format for representing error information in HTTP API responses. Create the middlewares/errorHandler.js
file with the following content:
import { ProblemDocument } from 'http-problem-details';
class AppError extends Error {
constructor(error, type, status, name) {
super(error);
this.type = type;
this.name = name;
this.status = status;
this.detail = error;
}
}
export class NotFoundError extends AppError {
constructor(error) {
super(error, 'resource-not-found', 404, 'NotFoundError');
}
}
export class ValidationError extends AppError {
constructor(error) {
super(error, 'validation-error', 400, 'ValidationError');
}
}
// eslint-disable-next-line no-unused-vars
export const errorHandler = (err, req, res, next) => {
if (err instanceof AppError) {
const problem = new ProblemDocument({
type: '/problems/' + err.type.toLowerCase(),
title: err.name,
status: err.status,
detail: err.detail,
instance: req.originalUrl,
});
res.status(err.status).json(problem);
} else {
if (process.env.NODE_ENV === 'production') {
res.status(500).json(
new ProblemDocument({
type: '/problems/internal-server-error',
title: 'InternalServerError',
status: 500,
instance: req.path,
})
);
} else {
res.status(err.status || 500).json({
error: err.message,
stack: err.stack,
timestamp: new Date().toISOString(),
path: req.path,
});
}
}
};
The code above implements a structured error handling process using the Problem Details for HTTP APIs standard. We create the AppError
base class that extends the native Error
class, adding structured properties for HTTP responses. In addition, a couple of specialized error classes are created: NotFoundError
and ValidationError
. The errorHandler
function operates as follows:
For custom
AppError
instances, it creates a standardizedProblemDocument
response with the appropriate status code.For unknown errors, in production, it returns a
ProblemDocument
response with a status code of 500. In development, it provides detailed error information, including the stack trace.
Schema Validation Middleware
To validate the requests, we will use Yup. Yup is a JavaScript tool for building schemas to parse and validate data. It lets us define schemas that describe the expected structure and types of data. We can then use these schemas to validate incoming data or transform it to match the defined structure. Run the following command to install the package:
npm install yup
Create the middlewares/schemaValidator.js
file with the following content:
import { ValidationError } from './errorHandler.js';
import * as yup from 'yup';
export const schemaValidator = schema => {
return async (req, res, next) => {
try {
if (schema.body) {
req.body = await schema.body.validate(req.body, {
abortEarly: false,
stripUnknown: true,
});
}
if (schema.query) {
req.validatedQuery = await schema.query.validate(req.query, {
abortEarly: false,
stripUnknown: true,
});
}
next();
} catch (error) {
if (error instanceof yup.ValidationError) {
return next(new ValidationError(error.errors));
}
throw error;
}
};
};
The code above creates middleware for validating HTTP requests using Yup. The schemaValidator
is a higher-order function that takes a schema
object and returns a middleware function for request validation. The schema
parameter expects an object that can contain:
schema.body
- A Yup schema for validating the request bodyschema.query
- A Yup schema for validating query parameters
If schema.body
exists, it validates req.body
using the schema and replaces the original req.body
with the validated and cleaned data. If schema.query
exists, it validates req.query
using the schema and stores the validated data in the req.validatedQuery
property (the req.query
property cannot be replaced). If validation fails, it creates a ValidationError
instance with all the errors and invokes the next
function.
Endpoints
Update the features/todos/addTodo.js
with the following content:
import db from '../../config/database.js';
import { v7 as uuidv7 } from 'uuid';
import * as yup from 'yup';
export const addTodoSchema = yup.object({
title: yup.string().required(),
});
export const addTodo = async (req, res) => {
const todo = {
id: uuidv7(),
title: req.body.title,
completed: false,
created_at: new Date(),
};
await db('todos').insert(todo);
res.status(201).json(todo);
};
A Yup schema is added to ensure the title is required in the body of the request. Update the features/todos/listTodos.js
with the following content:
import db from '../../config/database.js';
import * as yup from 'yup';
export const listTodosSchema = yup.object({
completed: yup.boolean().optional(),
title: yup.string().trim().optional(),
pageNumber: yup.number().integer().min(1).required(),
pageSize: yup.number().integer().min(1).max(100).required(),
});
export const listTodos = async (req, res) => {
const { completed, title } = req.validatedQuery;
let baseQuery = db('todos');
if (completed !== undefined) {
baseQuery = baseQuery.where('completed', completed === 'true');
}
if (title && title.trim()) {
baseQuery = baseQuery.where('title', 'ilike', `${title.trim()}%`);
}
const [{ count: total }] = await baseQuery.clone().count('* as count');
const items = await baseQuery
.select('*')
.orderBy('created_at', 'desc')
.limit(req.pagination.pageSize)
.offset(req.pagination.offset);
const totalCount = parseInt(total);
res.status(200).json({
items,
pageNumber: req.pagination.pageNumber,
pageSize: req.pagination.pageSize,
totalPages: Math.ceil(totalCount / req.pagination.pageSize),
totalItems: totalCount,
});
};
A Yup schema is added to validate the query parameters of the request. Update the features/todos/findTodo.js
with the following content:
import db from '../../config/database.js';
import {
ValidationError,
NotFoundError,
} from '../../middlewares/errorHandler.js';
const uuidv7Regex =
/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
export const ensureTodoFound = async (req, res, next, todoId) => {
if (!uuidv7Regex.test(todoId)) {
return next(
new ValidationError(
'The provided todoId does not match the UUIDv7 format'
)
);
}
const todo = await db('todos').where('id', todoId).first();
if (!todo) {
return next(new NotFoundError('Todo not found'));
}
req.todo = todo;
next();
};
export const findTodo = async (req, res) => {
res.status(200).json(req.todo);
};
In the code above, we modify the ensureTodoFound
function, which was previously used as a regular middleware, to become a parameter-triggered middleware. This middleware runs when a specific route parameter is present in the URL; in our case, it will be the todoId
parameter. In case of any error, we call the next
function with the appropriate parameter. Update the features/todos/routes.js
with the following content:
import express from 'express';
import { addTodo, addTodoSchema } from './addTodo.js';
import { findTodo, ensureTodoFound } from './findTodo.js';
import { checkTodo } from './checkTodo.js';
import { uncheckTodo } from './uncheckTodo.js';
import { listTodos, listTodosSchema } from './listTodos.js';
import { paginationParam } from '../../middlewares/paginationParam.js';
import { schemaValidator } from '../../middlewares/schemaValidator.js';
const router = express.Router();
router.param('todoId', ensureTodoFound);
router
.post('/', schemaValidator({ body: addTodoSchema }), addTodo)
.get('/:todoId', findTodo)
.post('/:todoId/check', checkTodo)
.post('/:todoId/uncheck', uncheckTodo)
.get(
'/',
schemaValidator({ query: listTodosSchema }),
paginationParam,
listTodos
);
export default router;
In the file above, we added the schema validator middleware and the parameter-triggered middleware.
More problems
Unhandled Routes
By default, Express has a generic middleware for unhandled routes that cannot be modified, but can be overridden by placing another middleware at the very end of our route definitions:
app.all('/*splat', (req, res, next) => {
const pathSegments = req.params.splat;
const fullPath = pathSegments.join('/');
next(new NotFoundError(`The requested URL /${fullPath} does not exist`));
});
app.all()
: Matches all HTTP methods (GET, POST, PUT, DELETE, etc.).'/*splat'
: This is a wildcard route pattern that matches any URL path.splat
is simply a parameter name, used to indicate that the matched path should be stored inreq.params.splat
.next(new NotFoundError())
: Passes a custom error to the error handling middleware.
Unhandled Rejections
Unhandled rejections happen when a promise is rejected (asynchronous code), but there is no .catch()
handler or try-catch
block to manage the rejection. Express will manage these cases when they occur during a request, but it doesn't handle the rest of the code. Fortunately, the solution is quite simple:
process.on('unhandledRejection', err => {
console.error(err.name, err.message);
server.close(() => {
process.exit(1);
});
});
Uncaught Exceptions
Uncaught exceptions are errors that happen during synchronous code execution and are not caught by any error-handling mechanism. The solution is also simple:
process.on('uncaughtException', err => {
console.error(err.name, err.message);
process.exit(1);
});
The final server.js
file will look like this:
import express from 'express';
import dotenv from 'dotenv';
import todosRoutes from './features/todos/routes.js';
import { errorHandler, NotFoundError } from './middlewares/errorHandler.js';
process.on('uncaughtException', err => {
console.error(err.name, err.message);
process.exit(1);
});
dotenv.config();
const PORT = process.env.PORT || 3000;
const app = express();
app.use(express.json());
app.use('/api/todos', todosRoutes);
app.all('/*splat', (req, res, next) => {
const pathSegments = req.params.splat;
const fullPath = pathSegments.join('/');
next(new NotFoundError(`The requested URL /${fullPath} does not exist`));
});
app.use(errorHandler);
const server = app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
process.on('unhandledRejection', err => {
console.error(err.name, err.message);
server.close(() => {
process.exit(1);
});
});
In this post, we explore how to handle the most common error scenarios that can happen when developing an API. You can find all the code here. Thanks, and happy coding.
Subscribe to my newsletter
Read articles from Raul Naupari directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Raul Naupari
Raul Naupari
Somebody who likes to code