Skip to content

Where validation belongs

Three chapters of schemas, all run by hand on objects written a few lines above them. Real input arrives over the network, and a schema is worth something only where that input lands.

The question is where to put the check, and the answer changes how the rest of the code is written.

The path a submission takes

A registration form posts to the server. Between the request arriving and your code running, one thing happens:

text
form  ──POST──▶  validation  ──▶  route handler  ──▶  response

                     └──▶  400 with the errors

Validation sits in the middle, and it decides which of two endings the request gets.

Fails. The schema finds problems, the request stops there, and a 400 Bad Request goes back carrying a list of what was wrong. The route handler never runs.

Passes. The parsed data continues to the route handler, which does the real work and answers with 201 Created.

The handler only ever receives data that already matched the schema.

JunoThe path a submission takes The scanner comparison still fits, and now the machine sits in a doorway instead of on a bench.

Nothing gets to the room behind it without going through, so the room can stop asking whether things were checked.

JunoThe path a submission takes The 400 is doing more than reporting a problem. It's how the front end knows which fields to mark, which is why the response carries a list of field-and-message pairs and not one sentence.

The status code matters on its own too. A 400 says the client sent something wrong and should change it before retrying. A 500 says the server broke. Returning 500 for a failed validation sends clients into retry loops over a request that will never succeed.

JunoThe path a submission takes Worth being deliberate about what a 400 body contains. Field names and your own messages are fine. The received value is not, because a rejected registration contains a password.

An error response is somewhere people forget they're writing data down, and the same goes for whatever you log on that path.

There's also a decision hiding in the diagram about where transformation happens. The parsed value is what continues, so trimming and lowercasing here means everything downstream sees the cleaned version and no handler has to remember to normalise.

That's the argument for putting it in the schema, and it holds only while handlers stop reading the raw body.

Why the check goes in front

The alternative is to validate at the top of each route handler, which works and doesn't hold.

js
// The version that drifts
import { registerSchema } from './schemas/userSchema.js'

app.post('/api/register', (req, res) => {
  const result = registerSchema.safeParse(req.body)
  if (!result.success) {
    return res.status(400).json({ success: false, errors: result.error.issues })
  }

  // ...the actual work, eventually
})

Every route repeats that block. Repeated code drifts: one route maps the errors and another returns them raw, one forgets the return and carries on into the handler after answering, a new route copies whichever version was nearest.

Putting the check in front makes it one piece of code every route shares, and makes a route's validation readable from its definition instead of its body.

JunoWhy the check goes in front Both versions run the same schema. The difference is how many copies of the surrounding code exist.

One copy can be fixed once. Twelve copies means finding out which of them has the bug.

JunoWhy the check goes in front The missing return is the specific bug worth knowing about, because it fails quietly. res.status(400).json(...) sends the response and does not stop the function, so without a return the handler keeps going and processes the invalid data anyway.

You get a 400 in the browser and a created user in the database. Everything looks correct from the outside, which is the worst kind of wrong.

JunoWhy the check goes in front The deeper win is that validation becomes declarative and therefore auditable. When each route names its schema in its definition, "which endpoints validate their input" is answered by reading a list, and a route with no schema is visible instead of merely undocumented.

Try answering that question across forty handlers that each validate internally. You cannot, without reading all forty, and the answer changes every time somebody adds a route.

It's also worth putting the same schema in front of the form, so the browser catches simple mistakes without a round trip. Same file, same rules, and the server check is still the one that counts, because the browser copy can be edited by whoever is running it.

What the handler receives

A handler behind validation gets to be short, because it can assume the shape it was given:

js
import { validate } from './middleware/validate.js'
import { registerSchema } from './schemas/userSchema.js'

app.post('/api/register', validate(registerSchema), (req, res) => {
  const userData = req.validatedData

  // Business logic, working from data that already matched the schema.

  res.status(201).json({ success: true, user: { email: userData.email } })
})

Three things sit between the path and the handler now: validate(registerSchema) is the check, and the handler runs only if it passed.

The handler reads req.validatedData, not req.body. That distinction is the whole arrangement. req.body is what arrived; req.validatedData is what survived, with any transformations applied.

JunoWhat the handler receives Two names for what looks like the same thing, and the difference matters.

req.body is whatever was sent. req.validatedData is what passed the check. Read the second one and the handler never has to wonder.

JunoWhat the handler receives This is the habit to enforce in review: once a route has validation in front of it, req.body should not appear in the handler at all. A single stray read of it steps around every check you set up.

It's a mechanical thing to look for, which makes it a good rule. Grep the handler for req.body, and if it's there behind a validate call, that's the finding.

JunoWhat the handler receives Reading req.body behind validation reintroduces mass assignment, and it's worth naming why. The schema strips keys it doesn't describe, so the parsed object contains only what you asked for. The raw body still contains everything that was sent, including the isAdmin a client added hopefully.

The fix is to route the data, not to remember a rule. When the parsed value is the only one handlers can reach, forgetting stops being possible, which beats a code review that catches it most of the time.

Try it

Here's a route that validates inside the handler:

js
import * as z from 'zod'
import { saveSubscriber } from './services/subscribers.js'

const subscribeSchema = z.object({
  email: z.email('Enter a valid email address'),
})

app.post('/api/subscribe', (req, res) => {
  const result = subscribeSchema.safeParse(req.body)

  if (!result.success) {
    res.status(400).json({ success: false, errors: result.error.issues })
  }

  const { email } = req.body
  saveSubscriber(email)

  res.status(201).json({ success: true })
})

Find three problems with it.

Compare your answers

1. The missing return. res.status(400).json(...) sends a response and keeps executing, so an invalid request gets a 400 and reaches saveSubscriber. The client sees a rejection while the subscriber is saved.

2. It reads req.body instead of result.data. Even with the return fixed, the value used is the raw one, so any trimming or lowercasing in the schema is discarded and anything the schema stripped is back.

3. It returns result.error.issues unchanged. Zod's issue objects carry internal detail, including the expected type and the failing constraint, and they're shaped for code. A client needs field-and-message pairs.

There's a fourth thing that isn't a bug in this route and becomes one at scale: all of this lives inside the handler, so the next route gets a copy, and the copies drift.

Where this goes next

The plan is settled. A check in front of the handler, a 400 with usable errors on failure, parsed data attached to the request on success.

Validation middleware builds it: one function that takes any schema and returns something Express can put in front of any route.