Dónde va la validación
Tres capítulos de esquemas, todos ejecutados a mano sobre objetos escritos unas líneas antes. El input real llega por la red, y un esquema vale algo solo donde ese input aterriza.
La pregunta es dónde poner el chequeo, y la respuesta cambia cómo se escribe el resto del código.
El camino que recorre un envío
Un formulario de registro hace un POST al servidor. Entre que llega la solicitud y corre tu código, pasa una cosa:
form ──POST──▶ validation ──▶ route handler ──▶ response
│
└──▶ 400 con los erroresLa validación se ubica en el medio, y decide cuál de los dos finales recibe la solicitud.
Falla. El esquema encuentra problemas, la solicitud se detiene ahí, y vuelve un 400 Bad Request con una lista de lo que estaba mal. El route handler nunca llega a ejecutarse.
Pasa. Los datos parseados continúan hacia el route handler, que hace el trabajo real y responde con 201 Created.
El handler solo recibe datos que ya coincidían con el esquema.
Nada llega a la sala que hay detrás sin pasar por ahí, así que la sala puede dejar de preguntarse si las cosas fueron revisadas.
Por qué el chequeo va adelante
La alternativa es validar al principio de cada route handler, lo cual funciona pero no se sostiene.
// La versión que se desvía
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 })
}
// ...el trabajo real, eventualmente
})Cada ruta repite ese bloque. El código repetido se desvía: una ruta mapea los errores y otra los devuelve tal cual, una se olvida del return y sigue de largo hacia el handler después de responder, una ruta nueva copia la versión que tenía más a mano.
Poner el chequeo adelante hace que sea un solo pedazo de código que todas las rutas comparten, y hace que la validación de una ruta se pueda leer desde su definición y no desde su cuerpo.
Una copia se puede arreglar una sola vez. Doce copias significa averiguar cuál de ellas tiene el bug.
Qué recibe el handler
Un handler que está detrás de la validación puede ser corto, porque puede asumir la forma que le dieron:
import { validate } from './middleware/validate.js'
import { registerSchema } from './schemas/userSchema.js'
app.post('/api/register', validate(registerSchema), (req, res) => {
const userData = req.validatedData
// Lógica de negocio, trabajando con datos que ya coincidían con el esquema.
res.status(201).json({ success: true, user: { email: userData.email } })
})Ahora hay tres cosas entre la ruta y el handler: validate(registerSchema) es el chequeo, y el handler corre solo si pasó.
El handler lee req.validatedData, no req.body. Esa distinción es todo el arreglo. req.body es lo que llegó; req.validatedData es lo que sobrevivió, con cualquier transformación ya aplicada.
req.body es lo que sea que se envió. req.validatedData es lo que pasó el chequeo. Lee el segundo y el handler nunca tiene que preguntarse nada.
Probalo vos
Acá hay una ruta que valida dentro del handler:
import * as z from 'zod'
import { saveSubscriber } from './services/subscribers.js'
const subscribeSchema = z.object({
email: z.email('Ingresa una dirección de correo válida'),
})
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 })
})Encontrá tres problemas con esto.
Compará tus respuestas
1. Falta el return. res.status(400).json(...) envía una respuesta y sigue ejecutando, así que una solicitud inválida recibe un 400 y además llega a saveSubscriber. El cliente ve un rechazo mientras el suscriptor se guarda igual.
2. Lee req.body en lugar de result.data. Aunque se arregle el return, el valor que se usa es el crudo, así que cualquier recorte de espacios o conversión a minúsculas del esquema se descarta y todo lo que el esquema había eliminado vuelve a estar presente.
3. Devuelve result.error.issues sin cambios. Los objetos de issue de Zod llevan detalle interno, incluyendo el tipo esperado y la restricción que falló, y están pensados para el código, no para las personas. Un cliente necesita pares de campo y mensaje.
Hay una cuarta cosa que no es un bug en esta ruta pero se convierte en uno a gran escala: todo esto vive dentro del handler, así que la próxima ruta recibe una copia, y las copias se van desviando.
Hacia dónde va esto ahora
El plan está definido. Un chequeo delante del handler, un 400 con errores utilizables cuando falla, datos parseados adjuntos a la solicitud cuando pasa.
Middleware de validación lo construye: una función que toma cualquier esquema y devuelve algo que Express puede poner delante de cualquier ruta.

