Validation middleware
The check belongs in front of the handler. Building it means writing one function that works for every route, whatever schema that route needs.
Express middleware is a function it calls on the way to a handler, with three arguments: the request, the response, and next, which passes control onward. So what we need is a function that produces one of those, with a schema baked in.
validate(registerSchema) -> middleware that checks against registerSchema
validate(loginSchema) -> middleware that checks against loginSchemaA function that returns a function is a higher-order function, and here it's a factory: called once when routes are set up, and the middleware it produced runs on every request.
The shape of the factory
Start with the outline, in back-end/middleware/validate.ts:
import type { Request, Response, NextFunction } from 'express'
import * as z from 'zod'
export function validate(schema: z.ZodType) {
return (req: Request, res: Response, next: NextFunction): void => {
// Validate req.body against the schema.
}
}Two layers, doing two jobs at two different times.
The outer validate runs once, while the app is starting, and its only argument is the schema. z.ZodType is the base class every Zod schema extends, so the parameter accepts an object schema, a string schema, anything.
The inner function is what Express holds onto and calls per request. It returns void because it doesn't hand a value back; it either answers the request or calls next.
The outer one runs once, when the app starts, and its job is to remember the schema. The inner one runs on every single request, and its job is to do the checking.
Rejecting a bad request
Inside the inner function, run the schema over the body:
const result = schema.safeParse(req.body)safeParse is the one to reach for here, because a failure is something to report rather than an exception to unwind.
When it fails, the response has to say which field and what was wrong:
if (!result.success) {
const errors = result.error.issues.map((issue) => ({
field: issue.path[0],
message: issue.message,
}))
res.status(400).json({ success: false, errors })
return
}Three things happen. The issues become field-and-message pairs, the response goes back as a 400 carrying them, and return stops the function.
That return is the whole safety of this. res.json() sends a response and keeps executing, so without it the function carries on to next() and the handler runs on data that failed a moment earlier.
res.json() only does the first. The return does the second, and leaving it out is the classic version of this bug.
Passing the good data through
Everything after that return runs only when validation passed:
;(req as any).validatedData = result.data
next()The parsed data is attached to the request, then next() hands control to the route handler, which reads req.validatedData and never touches req.body.
result.data rather than req.body matters here. Zod may have coerced a string to a number, trimmed whitespace, lowercased an email or dropped a key the schema didn't describe. The parsed value carries all of that. The raw body carries none of it.
Here's the whole file:
import type { Request, Response, NextFunction } from 'express'
import * as z from 'zod'
export function validate(schema: z.ZodType) {
return (req: Request, res: Response, next: NextFunction): void => {
const result = schema.safeParse(req.body)
if (!result.success) {
const errors = result.error.issues.map((issue) => ({
field: issue.path[0],
message: issue.message,
}))
res.status(400).json({ success: false, errors })
return
}
;(req as any).validatedData = result.data
next()
}
}next() is the handover. Until it's called, the request is sitting with this function and going no further. That's what makes the arrangement work: a request that fails never gets handed on, so the code behind it only ever sees data that passed.
Try it
This middleware has three bugs. Two stop it working; one is quiet and worse.
export function validate(schema: z.ZodType) {
return (req: Request, res: Response, next: NextFunction): void => {
const result = schema.safeParse(req.body)
if (!result.success) {
res.status(400).json({ success: false, errors: result.error.issues })
}
;(req as any).validatedData = req.body
next()
}
}Compare your answers
1. No return after the 400. The response goes out, execution continues, next() runs, and the handler processes a request that failed validation. The client sees a rejection and the work happens anyway.
2. req.body where result.data belongs. Even once the return is added, the handler receives the raw body. Every coercion, trim and lowercase in the schema is discarded, and any key the schema would have stripped is still attached.
3. Raw result.error.issues in the response. It works, so it's the quiet one. Zod's issue objects describe your schema to the client, including expected types and failing constraints, and they aren't shaped for a person to read. Map them to field-and-message pairs.
Bug 2 is the one worth dwelling on. The middleware appears to work: valid requests pass, invalid ones are rejected, and the tests go green. What's silently gone is every transformation the schema was doing, which surfaces much later as inconsistent data in the database.
Where this goes next
The middleware is finished and validates against any schema it's handed. What it hasn't been handed yet is a schema, or a route to sit in front of.
Building a validated endpoint writes both, then grows the schema field by field until every input on the registration form is checked.

