Pergaminos Rápidos: Express 5 rompió tu middleware de validación y nadie te avisó
Llevo un par de años usando Express v4.x para distintos proyectos, donde el uso de los middlewares ha sido mi mayor aliado para tener un código legible y fácil de leer. Entre esos middlewares está uno de validación con Zod, diría que es de los que más utilizo.
Un día decidí arrancar un proyecto desde cero, pero como ya estamos en Express 5, aproveché de actualizarme. Utilicé el middleware de validación de siempre y de la nada, el código dejó de funcionar. Nunca pensé que sería el middleware, por lo que tardé en encontrar el problema.
Si tú también usas Zod y middlewares para validar tus requests en Express, probablemente tengas algo parecido a esto:
export function validateSchemaMiddleware(
zodSchema: ZodType,
requestKey: RequestKey
) {
return (request: Request, _response: Response, next: NextFunction) => {
const data = zodSchema.parse(request[requestKey]);
Object.assign(request[requestKey], data);
next();
};
}Un middleware genérico que valida body, query o params según lo que le pases. Sencillo, reutilizable y limpio. Lo montas en la ruta, haces tu .parse(), asignas el resultado de vuelta al request y en el controller ya tienes la data validada y tipada. Funciona perfecto.
Hasta que un día decides crear un proyecto desde cero con Express 5.
Capítulo 1: El problema
Express 5 convirtió req.query en un getter. Esto significa que cada vez que accedes a req.query, Express parsea el query string desde la URL de nuevo. Ya no es un objeto mutable que puedas modificar con Object.assign.
En la práctica, tu middleware ejecuta el .parse() sin problemas, Zod valida correctamente, pero cuando intentas guardar el resultado de vuelta con Object.assign(request.query, data)... ese dato se pierde. El controller nunca lo recibe. Es como escribir en la arena justo antes de que llegue la ola.
Lo frustrante es que req.body y req.params siguen siendo mutables. Solo req.query cambió. Así que tu middleware funciona perfecto para body y params, pero se rompe silenciosamente para query.
Capítulo 2: ¿Qué dice internet?
Me puse a buscar y lo primero que encontré fue que no estoy solo en esto. De hecho, hay bastante gente debatiendo el tema en distintos repositorios sin llegar a un consenso claro:
- En express-validator, el issue #1325 reporta directamente que la sanitización de query params se rompió con Express 5 y pregunta cuál es el camino a seguir. A la fecha, sigue abierto y sin respuesta oficial.
- En el propio repositorio de Express, la discusión #6490 pregunta cómo sanitizar
req.queryen v5. Un maintainer responde que se useres.locals, el autor del issue le responde que eso no tiene sentido semántico, y un colaborador sugiere usar un query parser custom. Nadie queda del todo satisfecho. - En The Odin Project, el issue #30190 pide que actualicen su curso para recomendar
matchedData()en vez de leer directamente dereq.query, porque los alumnos están leyendo datos sin sanitizar y ni se enteran.
En resumen: el problema está identificado, hay gente pidiendo soluciones, y no existe una respuesta consolidada. Cada quien se las arregla como puede.
Después de bastante búsqueda y pruebas, las soluciones que encontré se resumen en cuatro:
- Usar
res.locals: Un maintainer de Express confirmó queres.localses el lugar recomendado para almacenar valores con scope por request. Pero semánticamente no me convence: estás guardando datos del request en una propiedad del response. Entiendo que en una API REST sin templates no hay riesgo real de filtrar información, pero el código queda raro. Quien lo lea después va a preguntarse por qué los query params validados viven en la respuesta. - Validar dentro del handler: Otro enfoque que encontré propone no usar middleware y en su lugar llamar a una función de validación directamente en el controller que retorna la data tipada. Funciona, pero te obliga a cambiar la arquitectura de middlewares por completo. Además, el formato queda bastante feo con el schema, el request y el response como parámetros en cada handler. Siempre que pueda, prefiero priorizar el Principio de Responsabilidad Única.
Object.definePropertyen la instancia: Un gist bastante ingenioso usaObject.definePropertypara reemplazar el getter dereq.querycon un valor concreto solo en esa instancia del request. No es un monkey-patch global, pero sigue siendo un hack y queda unif (requestKey === "query")en medio del middleware que huele mal.- Crear una nueva variable: Una opción que se ve tentadora y la cual es bastante pragmática. Simplemente crear una nueva variable dentro del
requestpara cada valor (comoreq.bodyParsed,req.QueryParsed, etc) y simplemente dar por hecho que existen en la request. El problema es que es un infierno trabajar con el tipado y nunca dejarás contento a Typescript.
Ninguna me convencía del todo.
Capítulo 3: La solución pragmática
Lo que terminé haciendo fue crear una propiedad locals en el request mediante declaration merging de Typescript:
// express.d.ts
declare namespace Express {
interface Request {
locals?: Record<string, unknown>;
}
}
Y el middleware quedó así:
export function validateSchemaMiddleware(
zodSchema: ZodType,
requestKey: RequestKey
) {
return (request: Request, _response: Response, next: NextFunction) => {
const data = zodSchema.parse(request[requestKey]);
request.locals = { ...(request.locals || {}), [requestKey]: data };
next();
};
}
En el controller, simplemente haces la inferencia con Zod:
app.get(
"/items",
validateSchemaMiddleware(itemQuerySchema, "query"),
(req, res) => {
const { page, limit } = req.locals?.query as z.infer<typeof itemQuerySchema>;
// Resto del código...
}
);
¿Es la solución más elegante del mundo? No. Al final del día estás creando un express.d.ts con un Record<string, unknown> y haciendo un cast en el controller. Nada que te haga ganar un premio de arquitectura. En lo personal no me gusta mucho alterar el tipado con declaration merging pero al ser algo tan pequeño cedí.
Funciona, es semánticamente correcto porque los datos validados viven donde deben (en el request, no en el response) y no hackea nada: no peleas contra el getter, no usas Object.defineProperty, no creas variables extras que desatan el caos en el tipado y no dependes de librerías externas. Si mañana necesitas pasar más cosas entre middlewares, req.locals está disponible sin tocar la declaración.
Capítulo 4: La interrogante fea
La versión anterior es la "buena práctica", ya que locals es opcional en el .d.ts, el controller usa ?. Nadie miente y nadie se equivoca con el tipado, pero hay algo que en lo personal me molesta un poco en esta sentencia:
const { page, limit } = req.locals?.query as z.infer<typeof itemQuerySchema>;Esa interrogante se ve demasiado fea en el código, y más aún si tomamos en cuenta que debe existir en cada controller que lea el req.locals. Después de un par de controllers, tanta honestidad me hace ruido visual.
Acá conviene mirar el contrato real del código: req.locals solo se lee donde corrió validateSchemaMiddleware. No existe (o no debería existir) un caso plausible en el que un controller intente leer req.locals.query sin haber montado primero el middleware, porque, si no lo montaste, no hay nada para leer.
Si te apoyas en ese contrato, puedes evitarte la interrogante quitando el ? del declaration type, quedando de esta forma:
// express.d.ts
declare namespace Express {
interface Request {
locals: Record<string, unknown>;
}
}
// CODE...
const { page, limit } = req.locals.query as z.infer<typeof itemQuerySchema>;
¿Le estás mintiendo al compilador? Solo un poquito. En una ruta que no ejecuta el middleware, req.locals realmente sería undefined. Pero esa ruta tampoco lo lee, así que la mentira nunca explota. La garantía la pone el middleware, no el tipo.
Es el típico trade-off entre honestidad de tipos y ergonomía de lectura. La estricta te obliga a ? en cada handler. La pragmática te lo evita a costa de un acuerdo de equipo: el único camino para escribir en req.locals es el middleware. Si todos saben eso, la interrogante sobra.
Yo terminé con la segunda.
Cierre
Sé que esto no es la solución definitiva y probablemente Express o la comunidad eventualmente ofrezcan algo mejor. Pero hasta que eso pase, esta es la forma en que yo lo hago: pragmática, sin hacks, y que deja el código legible para quien venga después.
Si encontraste una forma diferente de resolver esto, me encantaría leerla.
Referencias
- Compatibility with Express 5: req.query is now read-only — express-validator #1325
- v5: how to sanitize req.query? — expressjs/express Discussion #6490
- Recommend matchedData() instead of reading from req.query directly — TheOdinProject #30190
- Express 5.x — API Reference (req.query)
- Gist: Express 5 mutable query workarounds — thom-nic
Member discussion