Inicio / TypeScript / Node.js Backend con TypeScript / Validación de datos con Zod

Validación de datos con Zod

Esquemas de validación runtime con Zod: parse, safeParse y transformaciones.

🔒 Solo lectura
📖

Estás en modo lectura

Puedes leer toda la lección, pero para marcar progreso, hacer ejercicios y ganar XP necesitas una cuenta Pro.

Desbloquear por $9/mes

Validación de datos con Zod

¿Por qué Zod?

Los interface y type de TypeScript solo existen en tiempo de compilación. En runtime, cualquier dato que entra desde HTTP, archivos o bases de datos es unknown. Zod cierra esa brecha:

// Sin Zod — el compilador confía en ti, pero el dato puede ser cualquier cosa
const body = req.body as CreateUserDto; // ← mentira en tiempo de ejecución

// Con Zod — validación real en runtime + inferencia de tipos
const result = createUserSchema.safeParse(req.body);
// result.data está garantizado con el tipo correcto
pnpm add zod

Tipos básicos

import { z } from 'zod';

const stringSchema  = z.string();
const numberSchema  = z.number();
const booleanSchema = z.boolean();
const dateSchema    = z.date();
const nullSchema    = z.null();
const undefinedSchema = z.undefined();
const unknownSchema = z.unknown(); // acepta cualquier valor sin transformar
const neverSchema   = z.never();   // rechaza todo (útil en switch exhaustivo)

// Literales
const adminRole = z.literal('admin');

// Validar con parse (lanza ZodError si falla)
const name = z.string().parse("Ana"); // "Ana"
z.string().parse(42);                 // ❌ lanza ZodError

// Validar con safeParse (nunca lanza)
const result = z.string().safeParse(42);
if (!result.success) {
  console.log(result.error.issues); // array de errores
} else {
  console.log(result.data); // string garantizado
}

Strings con restricciones

const emailSchema = z
  .string()
  .min(1, 'El email es requerido')
  .max(255, 'El email es demasiado largo')
  .email('Formato de email inválido')
  .toLowerCase()        // transformar: convierte a minúsculas
  .trim();              // transformar: elimina espacios

const passwordSchema = z
  .string()
  .min(8, 'Mínimo 8 caracteres')
  .max(100)
  .regex(/[A-Z]/, 'Debe tener al menos una mayúscula')
  .regex(/[0-9]/, 'Debe tener al menos un número');

const slugSchema = z
  .string()
  .regex(/^[a-z0-9-]+$/, 'Solo letras minúsculas, números y guiones');

const urlSchema = z.string().url('URL inválida');

const uuidSchema = z.string().uuid('UUID inválido');

Números y fechas

const priceSchema = z
  .number()
  .positive('El precio debe ser positivo')
  .multipleOf(0.01, 'Máximo 2 decimales');

const ageSchema = z
  .number()
  .int('Debe ser entero')
  .min(18, 'Debes ser mayor de edad')
  .max(120);

// Coerción: útil para query params (siempre vienen como string)
const pageSchema = z.coerce.number().int().positive().default(1);
// z.coerce.number().parse("42") → 42 (convierte el string a número)

const futureDateSchema = z
  .date()
  .min(new Date(), 'La fecha debe ser futura');

// Coercionar string ISO a Date
const dateStringSchema = z.coerce.date();
// dateStringSchema.parse("2024-12-31") → Date object

Objetos (el uso más común en APIs)

const createUserSchema = z.object({
  name:     z.string().min(2).max(100).trim(),
  email:    z.string().email().toLowerCase(),
  password: z.string().min(8),
  role:     z.enum(['admin', 'editor', 'viewer']).default('viewer'),
  age:      z.number().int().min(18).optional(),
  metadata: z.record(z.string(), z.unknown()).optional(), // objeto con claves dinámicas
});

// Inferir tipo TypeScript automáticamente — fuente única de verdad
type CreateUserDto = z.infer<typeof createUserSchema>;
// {
//   name: string;
//   email: string;
//   password: string;
//   role: "admin" | "editor" | "viewer";
//   age?: number;
//   metadata?: Record<string, unknown>;
// }

// Para update: todos los campos opcionales
const updateUserSchema = createUserSchema
  .omit({ password: true })  // excluir password del update
  .partial();                // hacer todos opcionales
type UpdateUserDto = z.infer<typeof updateUserSchema>;

Métodos clave de objetos

const schema = z.object({ a: z.string(), b: z.number(), c: z.boolean() });

schema.pick({ a: true, b: true });    // solo a y b
schema.omit({ c: true });            // todo menos c
schema.partial();                     // todos opcionales
schema.required();                    // todos requeridos
schema.extend({ d: z.date() });       // añadir campos
schema.merge(otroSchema);             // combinar schemas

// Por defecto Zod elimina campos extra (strip)
// Para rechazarlos:
schema.strict();
// Para pasarlos tal cual:
schema.passthrough();

Arrays y uniones

// Array de strings
const tagsSchema = z.array(z.string().min(1)).min(1).max(10);

// Tupla (array con longitud y tipos fijos)
const coordSchema = z.tuple([z.number(), z.number()]); // [lat, lng]

// Unión
const idSchema = z.union([z.string().uuid(), z.number().int().positive()]);
// Forma más corta:
const roleSchema = z.enum(['admin', 'editor', 'viewer']);

// Discriminated union (más eficiente que union para objetos)
const eventSchema = z.discriminatedUnion('type', [
  z.object({ type: z.literal('click'),   x: z.number(), y: z.number() }),
  z.object({ type: z.literal('keydown'), key: z.string() }),
  z.object({ type: z.literal('scroll'),  delta: z.number() }),
]);
type Event = z.infer<typeof eventSchema>;

Transformaciones y refinements

// transform — transforma el valor después de validar
const trimmedString = z.string().transform(s => s.trim());

const normalizedEmail = z
  .string()
  .email()
  .transform(s => s.toLowerCase().trim());

// Transformar string a objeto
const jsonStringSchema = z
  .string()
  .transform((str, ctx) => {
    try {
      return JSON.parse(str) as unknown;
    } catch {
      ctx.addIssue({ code: 'custom', message: 'JSON inválido' });
      return z.NEVER; // indicar que falló
    }
  });

// refine — validación personalizada compleja
const passwordConfirmSchema = z
  .object({
    password:        z.string().min(8),
    confirmPassword: z.string(),
  })
  .refine(data => data.password === data.confirmPassword, {
    message: 'Las contraseñas no coinciden',
    path:    ['confirmPassword'], // campo al que atribuir el error
  });

// superRefine — para múltiples errores personalizados
const ageRangeSchema = z
  .object({ min: z.number(), max: z.number() })
  .superRefine((data, ctx) => {
    if (data.min >= data.max) {
      ctx.addIssue({
        code:    z.ZodIssueCode.custom,
        message: 'min debe ser menor que max',
        path:    ['min'],
      });
    }
  });

Validar variables de entorno

Una de las mejores aplicaciones de Zod: validar process.env al arrancar la app:

// src/config/env.ts
import { z } from 'zod';

const envSchema = z.object({
  NODE_ENV:     z.enum(['development', 'test', 'production']).default('development'),
  PORT:         z.coerce.number().int().positive().default(3000),
  DATABASE_URL: z.string().url('DATABASE_URL debe ser una URL válida'),
  JWT_SECRET:   z.string().min(32, 'JWT_SECRET debe tener al menos 32 caracteres'),
  JWT_EXPIRES:  z.string().default('7d'),
  LOG_LEVEL:    z.enum(['trace','debug','info','warn','error','fatal']).default('info'),
  REDIS_URL:    z.string().url().optional(),
});

// Lanza un error descriptivo si falta alguna variable
const parsed = envSchema.safeParse(process.env);

if (!parsed.success) {
  console.error('❌ Variables de entorno inválidas:');
  parsed.error.issues.forEach(issue => {
    console.error(`  ${issue.path.join('.')}: ${issue.message}`);
  });
  process.exit(1);
}

export const env = parsed.data;
// env.PORT es number (no string), env.NODE_ENV tiene autocompletado

Middleware de validación genérico

// src/middleware/validate.ts
import { Request, Response, NextFunction } from 'express';
import { ZodSchema, ZodError } from 'zod';

type ValidateTarget = 'body' | 'query' | 'params';

export function validate(schema: ZodSchema, target: ValidateTarget = 'body') {
  return (req: Request, res: Response, next: NextFunction): void => {
    const result = schema.safeParse(req[target]);

    if (!result.success) {
      res.status(422).json({
        error:   'Datos inválidos',
        details: result.error.errors.map(e => ({
          field:   e.path.join('.'),
          message: e.message,
          code:    e.code,
        })),
      });
      return;
    }

    // Reemplazar con datos transformados/sanitizados por Zod
    (req as any)[target] = result.data;
    next();
  };
}

// Uso en rutas
import { createUserSchema, updateUserSchema } from '../schemas/user.schema.js';
import { validate } from '../middleware/validate.js';

router.post('/',    validate(createUserSchema),  createUser);
router.patch('/:id', validate(updateUserSchema), updateUser);
router.get('/',    validate(paginationSchema, 'query'), getUsers);

Resumen

Operación Método
Validar (lanza error) schema.parse(data)
Validar (resultado seguro) schema.safeParse(data)
Inferir tipo TypeScript z.infer<typeof schema>
Hacer campos opcionales schema.partial()
Excluir campos schema.omit({ campo: true })
Transformar valor .transform(fn)
Validación personalizada .refine(fn, mensaje)
Coercionar tipos z.coerce.number(), z.coerce.date()

En la siguiente lección aprendemos a manejar variables de entorno de forma robusta con múltiples entornos y el patrón de objeto de configuración centralizado.

🔒

Ejercicio práctico disponible

Construye un validador de esquemas tipo Zod

Desbloquear ejercicios
// Construye un validador de esquemas tipo Zod
// Desbloquea Pro para acceder a este ejercicio
// y ganar +50 XP al completarlo

function ejemplo() {
    // Tu código aquí...
}

¿Te gustó esta lección?

Con Pro puedes marcar progreso, hacer ejercicios, tomar quizzes, ganar XP y obtener tu constancia.

Ver planes desde $9/mes