Express REST API with Validation
Building robust RESTful APIs in Node.js with Express requires careful attention to input validation. Unvalidated data can lead to security…
Building robust RESTful APIs in Node.js with Express requires careful attention to input validation. Unvalidated data can lead to security vulnerabilities, unexpected application behavior, and poor user experience. This article explores a practical approach to integrating schema-based validation using Zod directly within Express route handlers, ensuring data integrity from the moment it hits your API endpoints.
We will cover schema definition, middleware integration, error handling strategies, and how to apply these techniques to various HTTP methods, providing a solid foundation for secure and reliable API development.
Setting Up the Project and Dependencies
Before diving into the code, ensure you have Node.js (v16.x or newer recommended) and npm (or yarn) installed. We'll initialize a new project and add the necessary packages:
mkdir express-zod-api
cd express-zod-api
npm init -y
npm install express zod @types/express --save
npm install typescript ts-node nodemon --save-dev
# Initialize TypeScript config
npx tsc --init
Modify tsconfig.json to include:
{
"compilerOptions": {
"target": "es2020",
"module": "commonjs",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules"]
}
Create an src directory and an src/index.ts file. Our development workflow will use nodemon for automatic restarts:
// package.json scripts section
"scripts": {
"start": "node dist/index.js",
"dev": "nodemon --exec ts-node src/index.ts",
"build": "tsc"
}
Defining Validation Schemas with Zod
Zod is a TypeScript-first schema declaration and validation library. It allows you to define complex data structures and validate incoming data against them. We'll place our schemas in a dedicated file, e.g., src/schemas/user.ts.
// src/schemas/user.ts
import { z } from "zod";
export const createUserSchema = z.object({
name: z.string()
.min(3, { message: "Name must be at least 3 characters long." })
.max(50, { message: "Name cannot exceed 50 characters." }),
email: z.string()
.email({ message: "Invalid email address." })
.max(100, { message: "Email cannot exceed 100 characters." }),
password: z.string()
.min(8, { message: "Password must be at least 8 characters long." })
.regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*()_+])[A-Za-z\d!@#$%^&*()_+]{8,}$/, {
message: "Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character."
}),
role: z.enum(["admin", "user", "guest"]).default("user"),
});
export const updateUserSchema = z.object({
name: z.string()
.min(3, { message: "Name must be at least 3 characters long." })
.max(50, { message: "Name cannot exceed 50 characters." })
.optional(),
email: z.string()
.email({ message: "Invalid email address." })
.max(100, { message: "Email cannot exceed 100 characters." })
.optional(),
password: z.string()
.min(8, { message: "Password must be at least 8 characters long." })
.regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*()_+])[A-Za-z\d!@#$%^&*()_+]{8,}$/, {
message: "Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character."
})
.optional(),
isActive: z.boolean().optional(),
}).refine(data => Object.keys(data).length > 0, {
message: "At least one field must be provided for update.",
});
export const userIdParamSchema = z.object({
id: z.string().uuid({ message: "Invalid user ID format. Must be a UUID." }),
});
// Infer TypeScript types from Zod schemas
export type CreateUserPayload = z.infer;
export type UpdateUserPayload = z.infer;
export type UserIdParam = z.infer;
In this example:
createUserSchemadefines all required fields for user creation, including complex regex for password validation.updateUserSchemauses.optional()for fields that might not be present in a partial update. The.refine()method adds custom validation to ensure at least one field is provided.userIdParamSchemais used for validating URL parameters, specifically ensuring an ID is a valid UUID.z.inferis crucial for deriving TypeScript types from our Zod schemas, maintaining type safety throughout the application.
Implementing Validation Middleware
To keep route handlers clean, we'll create a generic validation middleware. This middleware will accept a Zod schema and validate the request body, query parameters, or route parameters against it.
// src/middleware/validate.ts
import { Request, Response, NextFunction } from "express";
import { AnyZodObject, ZodError } from "zod";
export const validate = (schema: AnyZodObject) =>
async (req: Request, res: Response, next: NextFunction) => {
try {
// Prioritize request body validation
await schema.parseAsync(req.body);
next();
} catch (error) {
if (error instanceof ZodError) {
// ZodError.flatten() provides a more readable error structure
return res.status(400).json({
message: "Validation error",
errors: error.flatten().fieldErrors, // Only field-specific errors
});
}
// Catch any other unexpected errors
return res.status(500).json({ message: "Internal server error during validation." });
}
};
// A more flexible validation middleware for different request parts
export const validateRequest = (schema: AnyZodObject) =>
async (req: Request, res: Response, next: NextFunction) => {
try {
const { body, query, params } = req;
// You can compose schemas here or pass a schema designed for specific parts
// For simplicity, this example assumes the schema validates against req.body directly
// Or you can create an "overall" schema like:
// z.object({
// body: bodySchema.optional(),
// query: querySchema.optional(),
// params: paramsSchema.optional(),
// }).parseAsync({ body, query, params });
// For direct body/query/params validation based on context
// We'll use a more targeted approach in the routes.
next(); // If the schema was designed for a specific part, it would be validated before this line.
} catch (error) {
if (error instanceof ZodError) {
return res.status(400).json({
message: "Validation error",
errors: error.flatten().fieldErrors,
});
}
next(error); // Pass other errors to the Express error handler
}
};
// Specific validation for request body
export const validateBody = (schema: AnyZodObject) =>
async (req: Request, res: Response, next: NextFunction) => {
try {
req.body = await schema.parseAsync(req.body); // Cast/transform and reassign validated body
next();
} catch (error) {
if (error instanceof ZodError) {
return res.status(400).json({
message: "Validation error for request body",
errors: error.flatten().fieldErrors,
});
}
next(error);
}
};
// Specific validation for request parameters (e.g., /users/:id)
export const validateParams = (schema: AnyZodObject) =>
async (req: Request, res: Response, next: NextFunction) => {
try {
req.params = await schema.parseAsync(req.params); // Cast/transform and reassign validated params
next();
} catch (error) {
if (error instanceof ZodError) {
return res.status(400).json({
message: "Validation error for request parameters",
errors: error.flatten().fieldErrors,
});
}
next(error);
}
};
// Specific validation for request query string
export const validateQuery = (schema: AnyZodObject) =>
async (req: Request, res: Response, next: NextFunction) => {
try {
req.query = await schema.parseAsync(req.query); // Cast/transform and reassign validated query
next();
} catch (error) {
if (error instanceof ZodError) {
return res.status(400).json({
message: "Validation error for request query",
errors: error.flatten().fieldErrors,
});
}
next(error);
}
};
We've created specialized middleware functions (validateBody, validateParams, validateQuery) to target specific parts of the request. This approach provides more granular control and clearer error messages. The parseAsync method is used to handle potential async refinements in schemas.
Integrating Validation into Express Routes
Now, let's wire up our schemas and middleware into the main Express application.
// src/index.ts
import express from "express";
import { z } from "zod";
import { createUserSchema, updateUserSchema, userIdParamSchema, CreateUserPayload, UpdateUserPayload, UserIdParam } from "./schemas/user";
import { validateBody, validateParams } from "./middleware/validate";
const app = express();
const PORT = process.env.PORT || 3000;
app.use(express.json()); // Middleware to parse JSON request bodies
// --- Routes with Zod Validation ---
// POST /users - Create a new user
app.post(
"/users",
validateBody(createUserSchema),
(req, res) => {
// req.body is now guaranteed to conform to CreateUserPayload type
const newUser: CreateUserPayload = req.body;
console.log("Creating user:", newUser);
// In a real application, you would save newUser to a database
// Assign a mock ID for demonstration
const createdUser = { id: `user-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`, ...newUser };
res.status(201).json({ message: "User created successfully", user: createdUser });
}
);
// GET /users/:id - Retrieve a user by ID
app.get(
"/users/:id",
validateParams(userIdParamSchema),
(req, res) => {
// req.params.id is now guaranteed to be a valid UUID string
const { id } = req.params as UserIdParam; // Type assertion after validation
console.log("Fetching user with ID:", id);
// In a real application, fetch user from DB using 'id'
const mockUser = {
id: id,
name: "John Doe",
email: "john.doe@example.com",
role: "user",
isActive: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
res.status(200).json(mockUser);
}
);
// PUT /users/:id - Update an existing user
app.put(
"/users/:id",
validateParams(userIdParamSchema),
validateBody(updateUserSchema),
(req, res) => {
const { id } = req.params as UserIdParam;
const updateData: UpdateUserPayload = req.body;
console.log(`Updating user ${id} with data:`, updateData);
// In a real application, find and update user in DB
const updatedUser = {
id: id,
name: updateData.name || "Jane Doe",
email: updateData.email || "jane.doe@example.com",
isActive: updateData.isActive ?? true,
role: "admin", // Example: role might be set by logic, not directly from payload
updatedAt: new Date().toISOString(),
};
res.status(200).json({ message: "User updated successfully", user: updatedUser });
}
);
// Basic GET route
app.get("/", (req, res) => {
res.send("Express API with Zod validation is running!");
});
// Global Error Handler (optional, but good practice for unhandled errors)
app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
console.error(err.stack);
res.status(500).send('Something broke!');
});
app.listen(PORT, () => {
console.log(`Server listening on port ${PORT}`);
console.log(`Access at http://localhost:${PORT}`);
});
Notice how validateBody and validateParams are directly chained as middleware before the route handler. If validation fails, the middleware immediately sends a 400 response with detailed error messages. If validation passes, req.body and req.params are guaranteed to conform to the Zod schema's inferred type, allowing us to confidently use them in the handler without further type checking or casting (though explicit casting like req.params as UserIdParam can sometimes help with TypeScript inference when using generic middleware).
Testing the API Endpoints
Let's use curl or a tool like Postman/Insomnia to test the validation.
Successful User Creation
curl -X POST http://localhost:3000/users \
-H "Content-Type: application/json" \
-d '{
"name": "Alice Wonderland",
"email": "alice@example.com",
"password": "Password123!",
"role": "user"
}'
Expected Output (Status 201 Created):
{"message":"User created successfully","user":{"id":"user-...", "name":"Alice Wonderland", "email":"alice@example.com", "password":"Password123!", "role":"user"}}
Invalid User Creation (Missing fields, invalid email, weak password)
curl -X POST http://localhost:3000/users \
-H "Content-Type: application/json" \
-d '{
"name": "Al",
"email": "invalid-email",
"password": "pass"
}'
Expected Output (Status 400 Bad Request):
{
"message": "Validation error for request body",
"errors": {
"name": [ "Name must be at least 3 characters long." ],
"email": [ "Invalid email address." ],
"password": [ "Password must be at least 8 characters long.", "Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character." ]
}
}
Updating a User (Partial update)
# Assuming a UUID like 550e8400-e29b-41d4-a716-446655440000
curl -X PUT http://localhost:3000/users/550e8400-e29b-41d4-a716-446655440000 \
-H "Content-Type: application/json" \
-d '{
"name": "Alice Updated",
"isActive": false
}'
Expected Output (Status 200 OK):
{"message":"User updated successfully","user":{"id":"550e8400-e29b-41d4-a716-446655440000", "name":"Alice Updated", "email":"jane.doe@example.com", "isActive":false, "role":"admin", ...}}
Invalid User ID for GET/PUT
curl -X GET http://localhost:3000/users/invalid-uuid
Expected Output (Status 400 Bad Request):
{
"message": "Validation error for request parameters",
"errors": {
"id": [ "Invalid user ID format. Must be a UUID." ]
}
}
Common Pitfalls and Considerations
- Middleware Order: Ensure
express.json()(orexpress.urlencoded()) is used *before* your validation middleware, otherwisereq.bodywill be undefined. - Error Handling Granularity: While
ZodError.flatten().fieldErrorsis often sufficient, Zod's.format()method offers even more detailed error structures, useful for complex UIs. - Performance: For extremely high-throughput APIs, consider the overhead of parsing and validating every request. Zod is generally very fast, but if you have deeply nested, massive schemas, it's worth profiling. For most applications, the overhead is negligible compared to the benefits of robust validation.
- Async Validation: Zod supports asynchronous validations (e.g., checking if an email already exists in the database). Remember to use
.parseAsync()in your middleware and ensure your route handler isasyncif using them. - Request Part Separation: Clearly define which part of the request (body, query, params) each schema validates. Our tailored
validateBody,validateParams, andvalidateQueryfunctions help enforce this. - Type Safety with Express: Express's
RequestandResponsetypes from@types/expressare generic. Whilez.inferprovides types for your data, integrating them perfectly withreq.bodyorreq.paramsin generic middleware often requires careful type assertions or custom type declarations for your specificRequestobject, or as shown, simply asserting within the route handler after validation.