Skip to content

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.

text
validate(registerSchema)  ->  middleware that checks against registerSchema
validate(loginSchema)     ->  middleware that checks against loginSchema

A 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:

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.

JunoThe shape of the factory Two functions stacked up is the part that takes a moment. It helps to read them as two separate moments in time.

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.

JunoThe shape of the factory The reason it has to be a factory is that Express decides the middleware signature. It will call your function with request, response and next, and there's no fourth slot to pass a schema through.

Closing over the schema is how you get an extra argument in. It's the same pattern behind almost every configurable middleware you'll meet, which is why cors() and helmet() are called rather than passed.

JunoThe shape of the factory Typing the parameter as z.ZodType keeps the factory general and gives up the specific type, so result.data comes back loosely typed. Making the factory generic over the schema, <T extends z.ZodType>, carries z.infer<T> through to what you attach on the request, and the handler gets real types with no assertion.

Worth doing once you have more than a couple of routes, and worth skipping while you're learning the pattern, because the generic version is harder to read at exactly the moment you're trying to understand the two-layer structure.

Rejecting a bad request

Inside the inner function, run the schema over the body:

ts
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:

ts
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.

JunoRejecting a bad request Sending a response and stopping the function are two separate actions, which is surprising the first time it catches you.

res.json() only does the first. The return does the second, and leaving it out is the classic version of this bug.

JunoRejecting a bad request The mapping step is what turns Zod's output into an API contract. Raw issues carry the expected type and the failing constraint, which describe your schema to whoever is asking.

Deciding the shape here also means every route answers the same way, so the front end writes one function to render errors and never has to care which endpoint produced them.

JunoRejecting a bad requestissue.path[0] is correct for a flat body and wrong the moment anything nests, because every field under one parent collapses to the same key and messages land on the wrong input. issue.path.join('.') stays unique and costs nothing to write now.

Two details for later. Array indices arrive in the path as numbers, so a failure inside a list gives items.2.quantity, while most form libraries want items[2].quantity. And validating only req.body leaves params and query strings unchecked, which is where an id used in a database lookup usually lives. Extending the factory to take { body, params, query } is a small change and closes a real gap.

Passing the good data through

Everything after that return runs only when validation passed:

ts
;(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:

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 => {
    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()
  }
}
JunoPassing the good data throughnext() 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.

JunoPassing the good data through Forgetting next() gives you a request that hangs with no response and no error, until the client times out. It's a confusing failure precisely because nothing appears in the logs.

The as any is there because Express's Request type has no validatedData property. It works and it turns off type checking for that assignment, so a typo in the property name compiles cleanly and the handler reads undefined.

JunoPassing the good data through The typed alternative to as any is declaration merging: extend Express's Request interface in a .d.ts file so validatedData is a real property everywhere. Costs a few lines once and removes the assertion from every middleware you write.

Overwriting req.body with the parsed value is the other approach, and it's tempting because handlers keep reading the property they already read. It also means a later middleware cannot tell validated from raw, and the type still claims to be whatever your body parser said. A separate property is worth the extra name.

Worth knowing that next(err) with an argument skips every remaining handler and goes to error middleware. If your app has a central error handler that already formats responses, passing the ZodError to it keeps formatting in one place instead of in every middleware.

Try it

This middleware has three bugs. Two stop it working; one is quiet and worse.

ts
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.