6 min read

Pergaminos Rápidos: Express 5 rompió tu middleware de validación y nadie te avisó

Ilustración conceptual de un request HTTP en Express 5 donde el query params queda fuera del middleware de validación con Zod

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.query en v5. Un maintainer responde que se use res.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 de req.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ó que res.locals es 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.defineProperty en la instancia: Un gist bastante ingenioso usa Object.defineProperty para reemplazar el getter de req.query con un valor concreto solo en esa instancia del request. No es un monkey-patch global, pero sigue siendo un hack y queda un if (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 request para cada valor (como req.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

Sigue leyendo "Pergaminos Rápidos"