Skip to content
This page has been auto-translated and may contain errors.View in English

Middleware de validación

La verificación debe ir delante del handler. Para construirla hay que escribir una función que funcione con cualquier ruta, sea cual sea el schema que esa ruta necesite.

Un middleware de Express es una función que se invoca en el camino hacia un handler, con tres argumentos: la solicitud, la respuesta y next, que pasa el control hacia adelante. Entonces lo que necesitamos es una función que produzca uno de esos middlewares, con un schema ya incorporado.

text
validate(registerSchema)  ->  middleware que valida contra registerSchema
validate(loginSchema)     ->  middleware que valida contra loginSchema

Una función que devuelve una función es una función de orden superior, y aquí funciona como una fábrica: se llama una sola vez cuando se configuran las rutas, y el middleware que produce se ejecuta en cada solicitud.

La estructura de la fábrica

Empieza con el esquema general, en 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 => {
    // Validar req.body contra el schema.
  }
}

Dos capas, que cumplen dos funciones en dos momentos distintos.

La función externa validate se ejecuta una sola vez, mientras la aplicación arranca, y su único argumento es el schema. z.ZodType es la clase base de la que extienden todos los schemas de Zod, así que el parámetro acepta un schema de objeto, un schema de string, lo que sea.

La función interna es la que Express conserva y llama en cada solicitud. Devuelve void porque no retorna ningún valor; o bien responde la solicitud o llama a next.

JunoLa estructura de la fábrica Tener dos funciones apiladas es la parte que toma un momento entender. Ayuda pensarlas como dos momentos separados en el tiempo.

La externa se ejecuta una sola vez, cuando arranca la aplicación, y su trabajo es recordar el schema. La interna se ejecuta en cada solicitud, y su trabajo es hacer la verificación.

JunoLa estructura de la fábrica Tiene que ser una fábrica porque Express decide cuál es la firma del middleware. Va a llamar a tu función con request, response y next, y no hay un cuarto espacio para pasar un schema.

Capturar el schema en un closure es la forma de meter un argumento extra. Es el mismo patrón detrás de casi cualquier middleware configurable que encuentres, por eso cors() y helmet() se llaman como funciones en vez de pasarse directamente.

JunoLa estructura de la fábrica Tipar el parámetro como z.ZodType mantiene la fábrica genérica, pero a cambio se pierde el tipo específico, así que result.data vuelve con un tipo poco preciso. Hacer que la fábrica sea genérica sobre el schema, <T extends z.ZodType>, propaga z.infer<T> hasta lo que adjuntas en el request, y el handler obtiene tipos reales sin necesidad de aserciones.

Vale la pena hacerlo una vez que tengas más de un par de rutas, y vale la pena omitirlo mientras estás aprendiendo el patrón, porque la versión genérica es más difícil de leer justo en el momento en que estás tratando de entender la estructura de dos capas.

Rechazar una solicitud incorrecta

Dentro de la función interna, ejecuta el schema sobre el body:

ts
const result = schema.safeParse(req.body)

safeParse es la opción indicada aquí, porque un fallo es algo que hay que reportar y no una excepción que haya que capturar.

Cuando falla, la respuesta tiene que decir qué campo falló y qué salió mal:

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
}

Pasan tres cosas. Los issues se convierten en pares de campo y mensaje, la respuesta se envía como un 400 que los lleva, y return detiene la función.

Ese return es toda la protección de esto. res.json() envía una respuesta pero sigue ejecutando código, así que sin él la función continúa hacia next() y el handler termina operando sobre datos que acaban de fallar la validación.

JunoRechazar una solicitud incorrecta Enviar una respuesta y detener la función son dos acciones separadas, algo que sorprende la primera vez que te atrapa.

res.json() solo hace la primera. El return hace la segunda, y omitirlo es la versión clásica de este error.

JunoRechazar una solicitud incorrecta El paso de mapeo es lo que convierte la salida de Zod en un contrato de API. Los issues en crudo llevan el tipo esperado y la restricción que falló, lo cual describe tu schema a quien esté haciendo la solicitud.

Definir la forma de la respuesta aquí también hace que cada ruta responda de la misma manera, así que el front end escribe una sola función para mostrar errores y nunca necesita saber qué endpoint los produjo.

JunoRechazar una solicitud incorrectaissue.path[0] funciona bien para un body plano, pero falla en cuanto algo se anida, porque todos los campos bajo un mismo padre colapsan a la misma clave y los mensajes terminan asociados al campo equivocado. issue.path.join('.') se mantiene único y no cuesta nada escribirlo desde ahora.

Dos detalles para más adelante. Los índices de arreglos llegan en el path como números, así que un fallo dentro de una lista produce algo como items.2.quantity, mientras que la mayoría de las librerías de formularios esperan items[2].quantity. Además, validar solo req.body deja sin revisar los params y los query strings, que es donde suele estar un id usado en una consulta a la base de datos. Extender la fábrica para que reciba { body, params, query } es un cambio pequeño y cierra una brecha real.

Dejar pasar los datos válidos

Todo lo que viene después de ese return solo se ejecuta cuando la validación pasó:

ts
;(req as any).validatedData = result.data
next()

Los datos ya parseados se adjuntan al request, y luego next() le pasa el control al handler de la ruta, que lee req.validatedData y nunca toca req.body.

Aquí importa usar result.data en vez de req.body. Zod puede haber convertido un string a número, recortado espacios en blanco, puesto un email en minúsculas o eliminado una clave que el schema no describía. El valor parseado conserva todo eso. El body en crudo no conserva nada de eso.

Aquí está el archivo completo:

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()
  }
}
JunoDejar pasar los datos válidosnext() es la entrega del control. Hasta que se llama, la solicitud se queda con esta función y no avanza más.

Eso es lo que hace que el mecanismo funcione: una solicitud que falla nunca se entrega, así que el código que viene después solo llega a ver datos que ya pasaron la validación.

JunoDejar pasar los datos válidos Olvidar next() te deja con una solicitud que queda colgada, sin respuesta ni error, hasta que el cliente agota el tiempo de espera. Es un fallo confuso precisamente porque no aparece nada en los logs.

El as any está ahí porque el tipo Request de Express no tiene una propiedad validatedData. Funciona, pero desactiva la verificación de tipos para esa asignación, así que un error de tipeo en el nombre de la propiedad compila sin problemas y el handler termina leyendo undefined.

JunoDejar pasar los datos válidos La alternativa tipada a as any es la fusión de declaraciones (declaration merging): extiende la interfaz Request de Express en un archivo .d.ts para que validatedData sea una propiedad real en todas partes. Cuesta unas cuantas líneas una sola vez y elimina la aserción de todos los middlewares que escribas.

Sobrescribir req.body con el valor parseado es el otro enfoque, y resulta tentador porque los handlers siguen leyendo la misma propiedad de siempre. El problema es que un middleware posterior ya no puede distinguir lo validado de lo crudo, y el tipo sigue afirmando ser lo que sea que dijo tu body parser. Una propiedad separada vale el nombre extra.

Vale la pena saber que next(err) con un argumento salta todos los handlers restantes y va directo al middleware de errores. Si tu aplicación tiene un manejador de errores central que ya formatea las respuestas, pasarle el ZodError mantiene el formateo en un solo lugar en vez de repetirlo en cada middleware.

Ponlo a prueba

Este middleware tiene tres errores. Dos hacen que deje de funcionar; uno es silencioso y peor.

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()
  }
}
Compara tus respuestas

1. Falta el return después del 400. La respuesta se envía, la ejecución continúa, next() se ejecuta, y el handler procesa una solicitud que falló la validación. El cliente ve un rechazo y de todos modos el trabajo se realiza.

2. req.body en el lugar donde debería ir result.data. Aunque se agregue el return, el handler recibe el body en crudo. Se descartan todas las conversiones, recortes y cambios a minúsculas que hacía el schema, y cualquier clave que el schema debería haber eliminado sigue presente.

3. result.error.issues sin procesar en la respuesta. Esto sí funciona, por eso es el silencioso. Los objetos issue de Zod describen tu schema al cliente, incluyendo los tipos esperados y las restricciones que fallaron, y no están pensados para que una persona los lea. Conviértelos en pares de campo y mensaje.

El error 2 es el que vale la pena analizar con calma. El middleware parece funcionar: las solicitudes válidas pasan, las inválidas se rechazan, y las pruebas dan en verde. Lo que desaparece en silencio es cada transformación que hacía el schema, algo que aparece mucho más tarde como datos inconsistentes en la base de datos.

Hacia dónde sigue esto

El middleware está terminado y valida contra cualquier schema que se le entregue. Lo que todavía no tiene es un schema, ni una ruta delante de la cual colocarse.

Construir un endpoint validado escribe ambas cosas, y luego va ampliando el schema campo por campo hasta que cada dato del formulario de registro queda verificado.