Zod vs express-validator: Dos enfoques para la validación en Express
Compara la validación de solicitudes basada en esquemas con Zod frente al middleware express-validator basado en cadenas, abordando la configuración, el formato de los errores y las trampas más comunes.
Manejar entrada no confiable es uno de los primeros problemas que debe resolver cualquier API de Express, y existen más de una forma de hacerlo: desde bibliotecas basadas en esquemas hasta validadores más procedimentales y basados en cadenas. Este artículo analiza ambos enfoques, comenzando con un método orientado a esquemas desarrollado con Zod.
Validación de solicitudes con Zod
Express no realiza ninguna validación por sí mismo en los datos recibidos. Sin una verificación en la entrada, los manejadores de rutas reciben directamente req.body, req.query y req.params tal como están: campos numéricos que en realidad son cadenas, campos que faltan por completo y cargas cuya estructura solo causa problemas una vez llegan a la lógica de negocio.
Zod aborda este problema al permitirte describir las formas de datos esperadas como esquemas basados en TypeScript. Definís un esquema una vez, deriváis un tipo estático a partir de él con z.infer, y analizáis los datos recibidos en la capa HTTP para que todo lo posterior solo reciba datos válidos. Cualquier elemento que no pase la validación puede convertirse en una respuesta HTTP 400 antes de que se ejecute el código del manejador.
Los ejemplos a continuación utilizan Zod 4 (z.email(), z.uuid(), z.coerce), además de un middleware de validación de Express, una herramienta compartida para formatear errores y una lista de posibles problemas.
Requisitos previos
Necesitará la versión 26 de Node.js, Zod 4 (npm i zod) y Express con sus definiciones de tipo (npm i express y npm i -D @types/express). La sintaxis en cadena de Zod 3 más antigua, como z.string().email(), sigue funcionando en la versión 4 pero está obsoleta; prefiera las funciones de nivel superior más recientes que se muestran a continuación.
Declaración de esquemas
// schemas.ts
import { z } from 'zod';
export const createUserSchema = z.object({
email: z.email(),
name: z.string().min(1).max(100),
age: z.number().int().min(0).max(150).optional()
});
export type CreateUserInput = z.infer<typeof createUserSchema>;
export const userIdParamSchema = z.object({
id: z.uuid()
});
export const listUsersQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(100).default(10),
q: z.string().trim().min(1).optional()
});
z.coerce.number() es útil para los valores de cadenas de consulta, ya que todo lo leído desde una consulta HTTP llega como cadena independientemente de su tipo lógico. Es mejor utilizar safeParse en lugar de parse en este punto para mantener el control sobre el estado HTTP resultante y el cuerpo de la respuesta.
Formateo consistente de errores
Convierta ZodError.issues en una única estructura JSON estable en lugar de formatear los errores por separado en cada ruta. Zod 4 también ofrece z.flattenError() para obtener un mapa de errores plano con claves de campo, y z.treeifyError() para una estructura anidada que refleje el esquema.
// format-zod-error.ts
import { ZodError } from 'zod';
export function formatZodError(error: ZodError) {
return {
message: 'Validation failed',
issues: error.issues.map((issue) => ({
path: issue.path.join('.') || '(root)',
message: issue.message,
code: issue.code
}))
};
}
Middleware de validación
Valide body, query y params antes de que se ejecute el manejador de la ruta, y luego escriba los valores analizados nuevamente para que el manejador reciba datos tipados y convertidos.
// validate.ts
import { NextFunction, Request, Response } from 'express';
import { ZodType } from 'zod';
import { formatZodError } from './format-zod-error';
type RequestSchemas = {
body?: ZodType;
query?: ZodType;
params?: ZodType;
};
export function validate(schemas: RequestSchemas) {
return (req: Request, res: Response, next: NextFunction) => {
const parseOrReject = (schema: ZodType, value: unknown) => {
const parsed = schema.safeParse(value);
if (!parsed.success) {
res.status(400).json(formatZodError(parsed.error));
return null;
}
return parsed.data;
};
if (schemas.body) {
const body = parseOrReject(schemas.body, req.body);
if (body === null) return;
req.body = body;
}
if (schemas.query) {
const query = parseOrReject(schemas.query, req.query);
if (query === null) return;
res.locals.query = query;
}
if (schemas.params) {
const params = parseOrReject(schemas.params, req.params);
if (params === null) return;
res.locals.params = params;
}
next();
};
}
Conéctelo en cada ruta de la siguiente manera:
app.post('/users', validate({ body: createUserSchema }), (req, res) => {
// req.body is CreateUserInput
res.status(201).json({ id: crypto.randomUUID(), ...req.body });
});
app.get('/users', validate({ query: listUsersQuerySchema }), (req, res) => {
const { limit, q } = res.locals.query;
// ...
});
app.get('/users/:id', validate({ params: userIdParamSchema }), (req, res) => {
const { id } = res.locals.params;
// ...
});
Los resultados de query y params se almacenan en res.locals porque los tipos de Express tratan req.query/req.params como mapas de cadenas simples; reemplazarlos directamente generaría conflictos con ese tipoado.
Riesgos
- Las cadenas de consulta son siempre cadenas; utilice
z.coerce(oz.string()junto con una transformación) para números y valores booleanos. parselanza unZodErrorsin procesar; o bien atrape el error y conviértalo en un 400 por su cuenta, o utilicesafeParseen su lugar.- Los esquemas de objetos Zod eliminan automáticamente las claves desconocidas; añada
.strict()para rechazarlas. - Los tipos inferidos como
CreateUserInputsolo existen en tiempo de compilación; siempre realice la解析 también en los puntos de entrada. - En Zod 4,
z.uuid()verifica según la especificación más reciente y estricta de UUID; si solo necesita un patrón genérico de ocho, cuatro, cuatro, cuatro y doce dígitos hexadecimales sin las reglas más estrictas, utilicez.guid()en su lugar.
Otra alternativa: validación basada en middleware con express-validator
Zod no es la única forma de evitar que los datos inválidos lleguen a tus controladores. Las aplicaciones Express han dependido durante mucho tiempo de express-validator, una biblioteca creada específicamente como middleware para Express, y adopta un enfoque diferente para resolver el mismo problema.
Imagina una solicitud de registro como esta:
{
"email": "hello",
"password": "123"
}
Si un controlador inspecciona directamente este contenido, cada campo requiere una verificación manual, lo que rápidamente se convierte en una serie interminable de condicionales que mezclan la validación con la lógica de negocio:
if (!email) ...
if (!email.includes("@")) ...
if (!password) ...
if (password.length < 8) ...
express-validator traslada esa lógica fuera del controlador y a un paso de middleware dedicado, de modo que la solicitud pasa por el proceso de validación antes de llegar a tu controlador:
Request
↓
Validation
↓
Controller
↓
Business Logic
Esa separación es precisamente el objetivo de la biblioteca: tu controlador queda libre para hacer únicamente lo que está diseñado para hacer.
Para comenzar, instala el paquete:
npm install express-validator
Importe la ayuda body y cree una cadena de validación para cada campo que le interese:
import { body } from "express-validator";
export const registerValidator = [
body("email")
.isEmail()
.withMessage("Invalid email"), body("password")
.isLength({ min: 8 })
.withMessage("Password must contain at least 8 characters"), body("username")
.notEmpty()
.withMessage("Username is required"),
];
Asocie ese middleware a la ruta, antes que al controlador:
router.post(
"/register",
registerValidator,
registerController
);
Definir las verificaciones por sí solas no es suficiente; aún necesita leer los errores recopilados durante la validación:
import { validationResult } from "express-validator";
const errors = validationResult(req);if (!errors.isEmpty()) {
return res.status(400).json({
errors: errors.array(),
});
}
Con esa verificación en su lugar, los payloads inválidos son rechazados con un código 400 antes de que se ejecute cualquier lógica de negocio.
Los validadores integrados cubren bien los casos comunes:
.isEmail()
.isLength()
.notEmpty()
.isInt()
Pero las aplicaciones reales a menudo necesitan reglas que la biblioteca no puede conocer de antemano; por ejemplo, durante el registro podría ser necesario verificar si un correo electrónico ya está en uso. Para eso existe .custom():
body("email")
.isEmail()
.bail()
.custom(async (email) => {
const user = await User.findOne({ email });
if (user) {
throw new Error("Email already registered");
} return true;
});
Los validadores personalizados pueden ser asíncronos, lo que los hace adecuados para búsquedas en la base de datos y otras verificaciones que dependen de la lógica del dominio propio. Observe la llamada a .bail() antes de la verificación personalizada: esta omite el resto de la cadena, incluida la búsqueda asíncrona, si el correo electrónico ya falló en la verificación con .isEmail(), evitando un viaje innecesario a la base de datos.
La elección entre estas dos bibliotecas —o Joi, otra opción consolidada— depende de lo que se adapte a su stack: express-validator es adecuado para proyectos ya construidos alrededor de los middleware de Express, Zod es ideal para entornos basados en TypeScript y con esquemas definidos, mientras que Joi representa una alternativa madura de uso general. No existe una opción universalmente correcta; depende de la arquitectura de su aplicación.
Cualquiera que sea la herramienta que elija, las fortalezas de express-validator son sus validadores integrados, ayudantes para la sanitización, validadores personalizados y asíncronos, su modelo de middleware y el manejo centralizado de errores. Un pipeline de solicitudes en Express bien estructurado generalmente se ve así:
Request
↓
Validator
↓
Controller
↓
Service
↓
Database
El objetivo no es solo confirmar que una cadena se parece a una dirección de correo electrónico, sino rechazar las entradas inválidas lo antes posible para que el resto de la aplicación permanezca limpia.
Lecturas relacionadas
- Propuestas TC39 en 2026: Decoradores, Temporal y Signals explicados — Una visión práctica de tres propuestas del TC39: decoradores nativos, la API Temporal y Signals, y su impacto en los desarrolladores de JavaScript y TypeScript full-stack.