Request Validation and Centralized Error Handling
Implement reusable Zod validation, request sanitization, custom errors, asynchronous error handling, and consistent API responses.
Why Request Validation Is Important
An API should never assume that incoming request data is correct.
Clients can send:
- Missing required properties.
- Incorrect data types.
- Invalid email addresses.
- Unexpected properties.
- Empty strings.
- Extremely large payloads.
- Malformed identifiers.
Without validation, invalid information may enter your database or cause unexpected application errors.
Validation should happen before business logic executes.
We will use Zod to define reusable validation schemas and validate request bodies, query parameters, and route parameters.
Never trust incoming client data.
Validate request payloads before business logic.
Use reusable validation schemas.
Create User Validation Schema
Create src/validators/user.validator.js.
We will define a schema for user registration.
Zod provides methods such as:
- string().
- email().
- min().
- max().
- number().
- int().
- optional().
- object().
- strict().
The strict method rejects unexpected object properties, helping prevent unwanted fields from being passed through.
bcrypt has a 72-byte input limit. For password hashing, enforce a byte-aware maximum or use an appropriate pre-hashing strategy rather than relying only on JavaScript character counts.
import { z } from 'zod';
export const createUserSchema = z.object({
name: z.string().trim().min(2).max(80),
email: z.string().trim().email().max(254),
password: z.string().min(8).max(72),
age: z.number().int().min(18).max(120).optional()
}).strict();
export const updateUserSchema = z.object({
name: z.string().trim().min(2).max(80).optional(),
email: z.string().trim().email().max(254).optional(),
age: z.number().int().min(18).max(120).optional()
}).strict().refine(
(data) => Object.keys(data).length > 0,
{ message: 'At least one field must be provided' }
);Defines strict registration and partial-update schemas. Password is accepted during registration, but deliberately excluded from the general update schema.
Zod provides reusable validation schemas.
strict() rejects unexpected properties.
Separate registration and update validation.
Create Reusable Validation Middleware
Create src/middleware/validate.middleware.js.
Instead of manually validating each request in every controller, create a reusable middleware.
The middleware receives a Zod schema and validates the request body.
If validation succeeds, replace req.body with the parsed and validated value.
If validation fails, return a structured 400 response or pass a validation error to centralized error middleware.
export function validateBody(schema) {
return (req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
success: false,
message: 'Validation failed',
errors: result.error.issues.map((issue) => ({
field: issue.path.join('.'),
message: issue.message
}))
});
}
req.body = result.data;
next();
};
}Creates middleware that validates request bodies, returns readable validation errors, and replaces the request body with parsed data.
Validation middleware avoids duplicated validation logic.
safeParse() returns structured validation results.
Only validated data should reach controllers.
Apply Validation to User Routes
Update src/routes/user.routes.js.
Apply the create schema to the POST endpoint and the update schema to the PATCH endpoint.
PUT should use a separate schema that requires all editable replacement fields. We will use the same underlying field constraints but require the fields explicitly.
PUT should require a complete editable representation. PATCH should allow a subset of editable fields. Do not use an optional-only schema for both operations.
import { Router } from 'express';
import { validateBody } from '../middleware/validate.middleware.js';
import {
createUserSchema,
updateUserSchema,
replaceUserSchema
} from '../validators/user.validator.js';
import {
createUser,
getAllUsers,
getUserById,
updateUser,
deleteUser
} from '../controllers/user.controller.js';
const router = Router();
router.post('/', validateBody(createUserSchema), createUser);
router.get('/', getAllUsers);
router.get('/:id', getUserById);
router.put('/:id', validateBody(replaceUserSchema), updateUser);
router.patch('/:id', validateBody(updateUserSchema), updateUser);
router.delete('/:id', deleteUser);
export default router;Applies validation middleware before controllers. replaceUserSchema must be defined in the validator module with all required editable fields.
Apply validation at the route level.
Validate before executing controller logic.
Keep PUT and PATCH semantics separate.
Create a Custom API Error Class
As an application grows, different services may produce different kinds of errors.
For example:
- User not found.
- Invalid credentials.
- Duplicate email.
- Permission denied.
- Invalid request.
Create a reusable custom error class so the application can carry an HTTP status code and a safe error message.
export class AppError extends Error {
constructor(message, statusCode = 500) {
super(message);
this.statusCode = statusCode;
this.isOperational = true;
Error.captureStackTrace(this, this.constructor);
}
}Creates an application error that contains a message, HTTP status code, and operational error indicator.
Custom errors represent expected application failures.
HTTP status codes can travel with errors.
Error classes make centralized handling easier.
Implement Centralized Error Handling
Create src/middleware/error.middleware.js.
Express error middleware receives four arguments:
(err, req, res, next)
It should be registered after all routes.
The middleware should distinguish operational errors from unexpected internal failures.
During production, avoid exposing stack traces, database internals, or secrets to clients.
Database connection strings, secrets, stack traces, and internal infrastructure details must not be exposed through production API responses.
export function errorHandler(error, req, res, next) {
console.error(error);
if (res.headersSent) {
return next(error);
}
const statusCode = error.statusCode || 500;
const isProduction = process.env.NODE_ENV === 'production';
res.status(statusCode).json({
success: false,
message:
statusCode === 500 && isProduction
? 'Internal server error'
: error.message || 'Something went wrong',
...(isProduction ? {} : { stack: error.stack })
});
}Provides a consistent error response and hides internal stack traces in production.
Express error middleware uses four parameters.
Register it after application routes.
Hide internal details in production.
Handle Common Mongoose Errors
Mongoose and MongoDB can produce several common errors.
Examples:
- ValidationError: Schema validation failed.
- CastError: A value cannot be cast to the expected type.
- MongoServerError code 11000: Duplicate key error.
These errors should be translated into meaningful API responses.
For example, a duplicate email should return HTTP 409 instead of exposing a raw database error.
export function errorHandler(error, req, res, next) {
console.error(error);
if (res.headersSent) return next(error);
let statusCode = error.statusCode || 500;
let message = error.message || 'Internal server error';
if (error.code === 11000) {
statusCode = 409;
message = 'A record with this value already exists';
} else if (error.name === 'ValidationError') {
statusCode = 400;
message = 'Database validation failed';
} else if (error.name === 'CastError') {
statusCode = 400;
message = 'Invalid resource identifier';
}
res.status(statusCode).json({
success: false,
message
});
}Translates common database errors into safe and meaningful HTTP responses.
MongoDB duplicate key errors use code 11000.
Mongoose validation errors should be handled explicitly.
Convert internal database failures into safe API responses.
Register Error Middleware
Update src/app.js and register the error handler after all routes.
The final middleware order should be:
- Security middleware.
- CORS middleware.
- Body parsing middleware.
- API routes.
- Not-found handler.
- Error handler.
The not-found handler catches requests that do not match any registered route.
import { errorHandler } from './middleware/error.middleware.js';
app.use('/api/v1', apiRoutes);
app.use((req, res) => {
res.status(404).json({
success: false,
message: `Route not found: ${req.method} ${req.originalUrl}`
});
});
app.use(errorHandler);Registers the not-found handler and centralized error middleware after all routes.
Middleware order is important.
Unknown routes should return 404.
Centralized error handling should be registered last.