Skip to content

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 zod

Middleware 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.

Academia Web — aprende, copia y construye.