Protegiendo el límite Express: Un middleware Zod para el cuerpo, los parámetros y la consulta
Aprenda cómo validar los cuerpos de solicitud, los parámetros de ruta y las cadenas de consulta en Express con un middleware Zod reutilizable, y cómo complementa la validación de modelos de Sequelize.
Nada impide que un cliente envíe un número donde tu API espera un nombre, o null donde espera una contraseña. El código que confía ciegamente en req.body termina escribiendo filas defectuosas o generando errores alejados de su verdadera causa. Esta guía muestra cómo describir una vez los datos de entrada válidos con Zod, aplicar esas reglas mediante un único middleware de Express que cubra el cuerpo de la solicitud, los parámetros de la ruta y la cadena de consulta, y mantener a los controladores enfocados en la lógica empresarial.
El problema: las solicitudes llegan sin tipado
Aquí hay un payload HTTP perfectamente válido que ningún endpoint de registro debería aceptar:
{
"fullName": 123,
"email": "hello",
"password": null
}
Cada campo tiene la forma incorrecta. Zod es una biblioteca de esquemas para JavaScript y TypeScript que te permite especificar con precisión qué esperas y obtener datos limpios o una lista estructurada de problemas.
Describiendo la entrada como un esquema
Supongamos que el registro requiere una cadena fullName, un email bien formado, una password de al menos ocho caracteres y un age numérico opcional. En Zod esto se expresa casi como los propios requisitos:
const { z } = require('zod');
const registerSchema = z.object({
fullName: z.string().min(2),
email: z.string().email(),
password: z.string().min(8),
age: z.number().int().min(18).optional()
});
Las reglas se encuentran en un único objeto en lugar de estar dispersas en sentencias if. La regla relativa a la edad también exige un mínimo de 18 años: si falta el valor de edad se acepta, pero si es 16 años no se aprueba.
Instalación e importación
Zod es una dependencia regular de npm:
npm install zod
En CommonJS, se carga el espacio de nombres z con require:
const { z } = require('zod');
Con módulos ES, se utiliza una importación por nombre:
import { z } from 'zod';
Agregar mensajes de error legibles
Cada validador acepta un mensaje opcional, que es lo que verán los clientes:
const registerSchema = z.object({
fullName: z.string().min(2, 'Full name is required'),
email: z.string().email('Invalid email'),
password: z
.string()
.min(8, 'Password must be at least 8 characters'),
age: z
.number()
.int()
.min(18)
.optional()
});
Un payload que cumple con todas las reglas pasa sin cambios:
{
"fullName": "John Smith",
"email": "john@example.com",
"password": "password123",
"age": 25
}
Este tiene un nombre demasiado corto, una dirección sin dominio y una contraseña de tres caracteres:
{
"fullName": "J",
"email": "invalid-email",
"password": "123"
}
Zod informa sobre los tres problemas al mismo tiempo, por lo que un formulario puede resaltar cada campo inválido en una sola solicitud. Las versiones recientes de Zod (v4 y posteriores) también ofrecen validadores de nivel superior como z.email() y deprecian el estilo en cadena z.string().email(). El formato en cadena sigue funcionando, pero consulte la documentación actual correspondiente a su versión.
Elegir entre parse() y safeParse()
parse() lanza excepciones
parse() devuelve los datos validados o lanza un ZodError:
const data = registerSchema.parse(req.body);
En un manejador de Express debe capturar la excepción usted mismo o reenviarla con next(err).
safeParse() devuelve un resultado
safeParse() nunca lanza excepciones. Devuelve un objeto con una bandera success, lo cual se adapta mejor al manejo de solicitudes porque la entrada inválida es un resultado esperado y no excepcional:
const result = registerSchema.safeParse(req.body);
En caso de error, error.issues enumera cada problema junto con su ruta y mensaje, listo para una respuesta 400:
if (!result.success) {
return res.status(400).json({
success: false,
errors: result.error.issues
});
}
En caso de éxito, result.data contiene el valor analizado:
const data = result.data;
Utilice result.data a partir de ahora, en lugar de req.body: las claves desconocidas se eliminan por defecto, y ya se han aplicado las coerciones y valores predeterminados.
De verificaciones incrustadas a un middleware reutilizable
La integración más simple llama a safeParse() dentro del manejador:
app.post('/register', (req, res) => {
const result = registerSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
success: false,
message: 'Validation failed',
errors: result.error.issues
});
}
const data = result.data;
console.log(data);
// Continue with registration logic...
return res.status(201).json({
success: true,
data
});
});
Funciona, pero con 20 o 50 puntos de extremo, las mismas líneas se pegan en cada controlador y gradualmente se van separando. También hay que notar que este ejemplo devuelve al cliente el objeto validado, incluida la contraseña; un verdadero punto de extremo debería devolver solo campos no sensibles.
Una fábrica validate()
La fábrica que se muestra a continuación toma un esquema y devuelve un manejador de Express. Valida el cuerpo, los parámetros y la consulta juntos, responde con 400 en caso de error y, de lo contrario, almacena el resultado analizado en req.validated antes de llamar a next():
const validate = (schema) => {
return (req, res, next) => {
const result = schema.safeParse({
body: req.body,
params: req.params,
query: req.query
});
if (!result.success) {
return res.status(400).json({
success: false,
message: 'Validation failed',
errors: result.error.issues
});
}
req.validated = result.data;
next();
};
};
module.exports = validate;
Dos detalles son importantes. Escribir en una propiedad separada req.validated evita problemas en Express 5, donde req.query es un getter y no puede ser reasignado simplemente. Además, como el middleware envuelve la entrada en { body, params, query }, los esquemas deben seguir esa estructura. Un registerSchema plano buscaría fullName en el nivel superior y rechazaría todas las solicitudes, por lo que hay que envolverlo en z.object({ body: registerSchema }), o hacer que el middleware valide únicamente req.body.
Conectarlo a una ruta
El middleware se coloca entre la ruta y el controlador:
router.post(
'/register',
validate(registerSchema),
register
);
La cadena de procesamiento de solicitudes queda así:
Request
↓
Express Router
↓
Zod Validation Middleware
↓
Controller
↓
Service
↓
Database
La entrada inválida se detiene en el middleware y el controlador nunca se ejecuta; la entrada válida continúa con datos que, por garantía, coinciden con el esquema.
Mantener los controladores centrados en la lógica de negocio
Sin una capa de validación, un controlador acumula todas las preocupaciones al mismo tiempo:
const register = async (req, res) => {
// validation
// check email
// validate password
// validate name
// business logic
// database operation
};
Con el middleware en su lugar, simplemente lee valores verificados:
const register = async (req, res) => {
const {
fullName,
email,
password
} = req.validated.body;
// Business logic
};
Como beneficio adicional, los esquemas pueden probarse unitariamente con objetos simples, y las pruebas de controlador ya no necesitan un caso para cada carga útil mal formada.
Validar parámetros de ruta mediante coerción
El mismo enfoque se aplica a los segmentos de URL. Tomemos una solicitud para un usuario:
GET /users/123
Un esquema para el parámetro id:
const userParamsSchema = z.object({
id: z.coerce.number().int().positive()
});
Adjunto como antes (bajo una clave params cuando se utiliza con el middleware anterior):
router.get(
'/users/:id',
validate(userParamsSchema),
getUser
);
El elemento clave es la coerción:
z.coerce.number()
Todo en una URL es texto. El valor de
req.params.id
llega como cadena
"123"
y no como número
123
Un simple z.number() rechazaría todas las solicitudes. z.coerce.number() primero procesa la entrada con Number() y luego aplica .int() y .positive(). Un caso límite: Number('') devuelve 0, por lo que un valor vacío se convierte en cero. Aquí .positive() lo detecta, pero un esquema sin límite inferior lo permitiría pasar.
Validación de cadenas de consulta con valores por defecto
La paginación es el caso clásico de cadena de consulta:
GET /users?page=1&limit=10
La coerción junto con valores por defecto genera números seguros incluso cuando el cliente los omite:
const userQuerySchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(10)
});
El límite de .max(100) también impide que un cliente solicite un millón de filas en una sola llamada.
Bloques básicos comunes de Zod
La mayoría de los esquemas combinan un pequeño conjunto de componentes:
z.string(),z.number()yz.boolean()verifican los tipos primitivos.z.object()describe la estructura de un objeto;z.array()valida un array y sus elementos.z.enum()limita un valor a una lista fija de opciones..min()y.max()establecen los límites para el valor de un número o la longitud de una cadena o array..email()verifica el formato del correo electrónico;.int()exige un número entero;.positive()requiere un valor mayor que cero..optional()permite que un campo esté ausente;.nullable()acepta explícitamente el valornull;.default()rellena los valores faltantes.z.coercees un espacio de nombres y no una función:z.coerce.number()y otros métodos convierten la entrada antes de validarla.
.refine() agrega reglas personalizadas; .transform() remodela un valor después de que pasa por él..parse() lanza un error en caso de fallo; .safeParse() devuelve un resultado de éxito o error.Ejemplo: un registro de usuario con roles
Un usuario en una aplicación de gestión de guarderías podría verse así:
const userSchema = z.object({
fullName: z.string().min(2),
email: z.string().email(),
role: z.enum([
'admin',
'teacher',
'parent'
]),
isActive: z.boolean().default(true)
});
z.enum() rechaza cualquier otro rol, y isActive tiene como valor por defecto true cuando no se especifica. El esquema también funciona como documentación.
Zod y Sequelize validan capas diferentes
Los equipos que trabajan con Sequelize y MySQL a menudo se preguntan por qué necesitan Zod si los modelos ya tienen validadores. Los dos protegen límites distintos.
Zod protege el límite de la API
Verifica lo que llega a través de HTTP antes de que el código de la aplicación actúe sobre ello:
HTTP Request
↓
Zod
↓
Controller
Sequelize protege la capa de datos
Sus validadores se ejecutan cuando se guarda un modelo, en lo más profundo de la capa de servicio:
Controller
↓
Service
↓
Sequelize
↓
MySQL
Uso de ambos
Juntos forman dos capas independientes:
Client
↓
Express
↓
Zod
↓
Controller
↓
Service
↓
Sequelize
↓
MySQL
Zod proporciona respuestas 400 rápidas y amigables para el cliente; Sequelize detecta errores que se originan dentro de la aplicación, como tareas en segundo plano que generan registros inválidos. Las restricciones de la base de datos como NOT NULL e índices únicos siguen siendo la última red de seguridad.
Organización de esquemas en una base de código más grande
En un proyecto basado en módulos, cada módulo cuenta con un archivo de validación junto a sus rutas, controlador y servicio, mientras que el middleware compartido se encuentra en su propia carpeta:
src/
├── modules/
│ └── users/
│ ├── user.controller.js
│ ├── user.service.js
│ ├── user.routes.js
│ └── user.validation.js
│
├── middleware/
│ └── validate.js
│
└── app.js
user.validation.js exporta los esquemas del módulo:
const { z } = require('zod');
const createUserSchema = z.object({
fullName: z.string().min(2),
email: z.string().email(),
password: z.string().min(8)
});
module.exports = {
createUserSchema
};
y el archivo de rutas se mantiene breve:
router.post(
'/users',
validate(createUserSchema),
createUser
);
Cuando un campo cambia, el controlador y sus reglas se editan juntos. Para reutilizar los mismos esquemas en el navegador, consulte compartir un esquema Zod entre React y Node.
Por qué una única fuente de verdad es beneficiosa
Sin un esquema, la validación se introduce en los controladores como verificaciones ad hoc:
if (!email) {
// ...
}
if (!password) {
// ...
}
if (password.length < 8) {
// ...
}
if (!['admin', 'teacher'].includes(role)) {
// ...
}
Cada punto de extremo repite una versión ligeramente diferente, y nadie puede ver el contrato completo de un vistazo. El esquema equivalente lo expresa en unas pocas líneas:
const userSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
role: z.enum(['admin', 'teacher'])
});
Ese es el acuerdo entre la API y los clientes, aplicado en un único lugar. Para compararlo con otro enfoque popular, consulte Zod versus express-validator.
Puntos clave
El verdadero valor radica en el orden de las responsabilidades que impone Zod:
Request
↓
Validation
↓
Controller
↓
Business Logic
↓
Database
- Validar en la entrada con
safeParse()y permitir que soloresult.datallegue a los manejadores. - Centralizar la validación en un único middleware, y asegurarse de que cada esquema coincida con la estructura que analiza.
- Utilizar
z.coercepara los parámetros y cadenas de consulta, y establecer límites en valores como el tamaño de la página. - Mantener los validadores del ORM y las restricciones de la base de datos como una segunda capa, no como sustitutos.
- Colocar los esquemas junto a sus módulos para que los cambios en el contrato se reflejen automáticamente en el código.
Lecturas relacionadas
- 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.
- Compartir un mismo esquema Zod entre tu frontend React y backend Node — Aprende cómo un único esquema Zod puede validar formularios de React, respuestas de API, cuerpos de solicitudes de Express y variables de entorno, al tiempo que genera tipos correspondientes en TypeScript.