taylor_kelley

Blog

API Request Validation with Zod + Typescript

Use req.body and req.params without worrying about bad or malformed data, while keeping your code type safe and consistent

March 2026

When coding a REST API in TypeScript, oftentimes the request object's body or params fields are typed as any. However this defeats the entire purpose of using TypeScript, along with making code insecure if the data is directly used in database queries. Zod is a potential solution to this problem.

Prerequisites

The framework used in this post is Express v5 with TypeScript. This is a Node JS framework which is used alongside the pnpm package manager to create a minimal, type-safe API. Before following along with this post, I recommend setting up a basic API to allow for testing this functionality in real-time.

Defining the Validation Schema

First things first, we need the schema that our data should follow. I like to place this in a folder named dto to denote them as Domain Transfer Objects. Along with placing them in this folder, I follow a naming convention of {endpointName}.dto.ts.

1// src/dto/addProduct.dto.ts
2
3// Create the Zod schema that is used to validate the request body
4const addProductSchema = z.object({
5	name: z.string('Must be a string').min(3, 'Product name must be at least 3 characters long'),
6	price: z.number('Price must be a number'),
7});
8
9// Add types for easily typing a controller with
10type AddProductReqBody = z.infer<typeof addProductSchema>;
11
12export { addProductSchema, type AddProductReqBody };

The above code snippet shows what a normal .dto.ts file would look like, including the Zod object schema that your route's req.body should match, paired with a type to easily pass to the controller function so that the req.body object doesn't have to be typed as any.

Creating the Middleware

To easily use this Zod schema to validate our request body, we can make a middleware function that allows for us to easily pass in the schema and return a structured error message if the validation fails.

1// src/middleware/validate.ts
2
3// Create a wrapper function that accepts the schema as a parameter, letting you call it in a route like:
4// `app.get('/', validateBody(addProductSchema), addProductController);`
5export function validateBody<T extends ZodType>(
6  schema: T
7): RequestHandler {
8	return async (req, res, next) => {
9		try {
10		    // Safely parse `req.body`, throwing an error if it doesn't pass validation
11		    const validatedBody = await schema.parseAsync(req.body);
12			
13			// Re-assign `req.body` to be the safely parsed and validated version
14			req.body = validatedBody;
15			
16		    next();
17	    } catch (err) {
18		    if (err instanceof ZodError) {
19			    // If the schema didn't validate properly, return an error with the fields that were wrong
20			    const errorTree = treeifyError<T>(err as ZodError<T>).properties;
21			    return res.status(400).json({
22			        success: false,
23			        message: 'Invalid request body',
24			        errors: errorTree,
25			    });
26		    } else {
27				next(err);
28		    }
29	    }
30	};
31}

Using the Middleware

This middleware is super easy to use, as all it takes is calling validateBody(zodSchemaObject) in whatever express route that you want to be parsed.

productRouter.post('/', validateBody(addProductSchema), addProductController);

Best Practices

To go from a basic implementation to a production ready implementation, I recommend keeping the following items in mind.

  • z.coerce for params: If using this validation on the params property, keep in mind that oftentimes these fields come as strings and need to be parsed into numbers or dates. Zod can use the z.coerce.number() and z.coerce.date() function's to convert the string values into numbers or dates.
  • Middleware location: The middleware should always come before your controller's, as it is otherwise ineffective, while also coming after your bodyParser or express.json() middleware so that the req.body property is set from the raw request.
  • Type Safety: Use the z.infer<> based type defined in the dto file to properly type a controller function so that calling req.body.name actually references the string value but req.body.invalid will display an error.

With this validate function, your API routes can become rock-solid, showing the user detailed error messages whenever their request body or parameters have an invalid format or missing fields. While it may feel like excess boilerplate upfront, it can save hours down the road while testing or debugging the API, making it an essential pattern for any modern TypeScript-based API.