Tema
Validación con zod
zod te permite describir la forma exacta de los datos que esperas (tipos, longitudes, formatos) y validarlos en una línea. Si algo no cuadra, obtienes errores claros por campo. Además transforma: convierte "15" en 15, recorta espacios y pone valores por defecto.
Instalación
bash
npm install zodMiddleware reutilizable
js
import { ZodError } from 'zod';
/**
* Valida body, query y/o params con esquemas de zod.
* Los datos ya limpios y transformados reemplazan a los originales.
*
* Uso: router.post('/', validar({ body: crearProductoSchema }), ctrl.crear)
*/
export const validar = (esquemas) => (req, res, next) => {
try {
if (esquemas.params) req.params = esquemas.params.parse(req.params);
if (esquemas.query) req.validQuery = esquemas.query.parse(req.query); // req.query es de sólo lectura en Express 5
if (esquemas.body) req.body = esquemas.body.parse(req.body);
next();
} catch (err) {
if (err instanceof ZodError) {
return res.status(422).json({
error: 'Datos no válidos',
detalles: err.issues.map((i) => ({ campo: i.path.join('.'), mensaje: i.message })),
});
}
next(err);
}
};Esquemas de ejemplo
js
import { z } from 'zod';
export const idParam = z.object({
id: z.coerce.number().int().positive('El id debe ser un número positivo'),
});
export const crearProductoSchema = z.object({
nombre: z.string({ required_error: 'El nombre es obligatorio' })
.trim()
.min(3, 'Mínimo 3 caracteres')
.max(150, 'Máximo 150 caracteres'),
descripcion: z.string().trim().max(2000).optional().nullable(),
precio: z.coerce.number({ invalid_type_error: 'El precio debe ser un número' })
.positive('Debe ser mayor a 0')
.multipleOf(0.01, 'Máximo 2 decimales'),
stock: z.coerce.number().int().min(0).default(0),
categoriaId: z.coerce.number().int().positive().optional(),
etiquetas: z.array(z.string().trim().min(1)).max(10).default([]),
activo: z.boolean().default(true),
});
// Para PATCH: todos los campos opcionales, pero al menos uno
export const actualizarProductoSchema = crearProductoSchema.partial().refine(
(d) => Object.keys(d).length > 0,
{ message: 'Envía al menos un campo para actualizar' }
);
export const listarProductosQuery = z.object({
pagina: z.coerce.number().int().min(1).default(1),
limite: z.coerce.number().int().min(1).max(100).default(20),
q: z.string().trim().max(100).optional(),
orden: z.enum(['nombre', 'precio', 'creado_en']).default('creado_en'),
dir: z.enum(['asc', 'desc']).default('desc'),
});js
import { z } from 'zod';
export const registroSchema = z.object({
nombre: z.string().trim().min(2, 'Escribe tu nombre'),
email: z.string().trim().toLowerCase().email('Correo no válido'),
telefono: z.string().regex(/^\d{10}$/, 'El teléfono debe tener 10 dígitos').optional(),
password: z.string()
.min(8, 'Mínimo 8 caracteres')
.regex(/[A-Z]/, 'Incluye al menos una mayúscula')
.regex(/\d/, 'Incluye al menos un número'),
confirmar: z.string(),
aceptaTerminos: z.literal(true, { errorMap: () => ({ message: 'Debes aceptar los términos' }) }),
}).refine((d) => d.password === d.confirmar, {
message: 'Las contraseñas no coinciden',
path: ['confirmar'], // el error se asigna a este campo
});
export const reservaSchema = z.object({
servicioId: z.coerce.number().int().positive(),
fecha: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Formato AAAA-MM-DD')
.refine((f) => new Date(f + 'T23:59:59') >= new Date(), 'La fecha no puede ser pasada'),
hora: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/, 'Formato HH:MM'),
notas: z.string().max(500).optional(),
});Usarlo en las rutas
js
import { Router } from 'express';
import { validar } from '../middlewares/validar.js';
import * as s from '../schemas/producto.schema.js';
import * as ctrl from '../controllers/productos.controller.js';
const router = Router();
router.get('/', validar({ query: s.listarProductosQuery }), ctrl.listar);
router.get('/:id', validar({ params: s.idParam }), ctrl.obtener);
router.post('/', validar({ body: s.crearProductoSchema }), ctrl.crear);
router.patch('/:id', validar({ params: s.idParam, body: s.actualizarProductoSchema }), ctrl.actualizar);
export default router;En el controlador, req.body ya está limpio: precio es número, nombre sin espacios sobrantes, stock tiene 0 por defecto y los campos desconocidos fueron eliminados (zod descarta claves que no están en el esquema).
Respuesta de error
json
{
"error": "Datos no válidos",
"detalles": [
{ "campo": "nombre", "mensaje": "Mínimo 3 caracteres" },
{ "campo": "precio", "mensaje": "Debe ser mayor a 0" }
]
}Tipos de TypeScript gratis
ts
import { z } from 'zod';
import { crearProductoSchema } from './producto.schema';
// El tipo se deduce del esquema: una sola fuente de verdad
export type CrearProducto = z.infer<typeof crearProductoSchema>;Validar también variables de entorno
js
import 'dotenv/config';
import { z } from 'zod';
const esquema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(),
JWT_ACCESS_SECRET: z.string().min(32, 'El secreto JWT debe tener al menos 32 caracteres'),
});
const resultado = esquema.safeParse(process.env);
if (!resultado.success) {
console.error('❌ Variables de entorno inválidas:', resultado.error.flatten().fieldErrors);
process.exit(1);
}
export const env = resultado.data;Comparte esquemas con el frontend
zod funciona igual en el navegador. Si tu frontend usa un bundler (Vite, Next.js), importa los mismos esquemas para validar formularios antes de enviarlos y mostrar errores al instante.
Error común
Usar z.number() para datos que vienen de la URL o de formularios. Todo lo que llega por query string es texto ("15"), así que falla. Usa z.coerce.number() para convertirlo.