Blog

RA

Rod Alexanderson

Desarrollador Web

Creando documentación técnica en español para desarrolladores de Latinoamérica.

Más sobre mí →

Suscríbete al Newsletter

Recibe los nuevos artículos directamente en tu email.

Zod: Validación de Datos en TypeScript

Zod es una librería de validación de datos para TypeScript. Piensa en ella como un guardia de seguridad que verifica que los datos que entran a tu aplicación sean correctos.

¿Por qué necesitas Zod?

Imagina que tienes un formulario de registro:

// ❌ Sin validación
function registrarUsuario(datos: any) {
  // ¿Qué pasa si email no es un email?
  // ¿Qué pasa si edad es negativa?
  // ¿Qué pasa si password está vacío?
  await db.usuario.create({ data: datos })
}

Problemas:

  • Datos inválidos en tu base de datos
  • Errores difíciles de debuggear
  • Experiencia de usuario mala
  • Vulnerabilidades de seguridad

Con Zod:

import { z } from 'zod'

const UsuarioSchema = z.object({
  email: z.string().email(),
  edad: z.number().min(18).max(120),
  password: z.string().min(8),
})

function registrarUsuario(datos: unknown) {
  // Valida antes de usar
  const usuarioValido = UsuarioSchema.parse(datos)
  
  // Ahora estás seguro de que los datos son correctos
  await db.usuario.create({ data: usuarioValido })
}

Instalación

npm install zod

Eso es todo. Zod no tiene dependencias.

Conceptos básicos

Tu primer schema

Un schema es como un molde que describe cómo deben verse tus datos:

import { z } from 'zod'

// Schema: "un string"
const nombreSchema = z.string()

// Validar
nombreSchema.parse('Juan')  // ✅ OK
nombreSchema.parse(123)     // ❌ Error: Expected string, received number

Tipos primitivos

// Strings
z.string()

// Numbers
z.number()

// Booleans
z.boolean()

// Dates
z.date()

// undefined
z.undefined()

// null
z.null()

// any (acepta cualquier cosa)
z.any()

Objetos

const ProductoSchema = z.object({
  nombre: z.string(),
  precio: z.number(),
  enStock: z.boolean(),
})

// Usar
ProductoSchema.parse({
  nombre: 'Camisa',
  precio: 25,
  enStock: true
})  // ✅ OK

ProductoSchema.parse({
  nombre: 'Camisa',
  precio: '25',  // ❌ Error: precio debe ser number
  enStock: true
})

Arrays

// Array de strings
const nombresSchema = z.array(z.string())

nombresSchema.parse(['Juan', 'María', 'Pedro'])  // ✅ OK
nombresSchema.parse(['Juan', 123, 'Pedro'])      // ❌ Error

// Array de objetos
const productosSchema = z.array(z.object({
  nombre: z.string(),
  precio: z.number(),
}))

productosSchema.parse([
  { nombre: 'Camisa', precio: 25 },
  { nombre: 'Pantalón', precio: 40 },
])  // ✅ OK

Validaciones comunes

Strings

const EmailSchema = z.string()
  .email('Email inválido')
  .min(5, 'Email muy corto')
  .max(100, 'Email muy largo')

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

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

// Expresión regular
const TelefonoSchema = z.string()
  .regex(/^\+?[1-9]\d{1,14}$/, 'Teléfono inválido')

// Empieza/termina con
const CodigoSchema = z.string()
  .startsWith('CODE-', 'Debe empezar con CODE-')
  .endsWith('-END', 'Debe terminar con -END')

Numbers

const EdadSchema = z.number()
  .min(18, 'Debes ser mayor de edad')
  .max(120, 'Edad inválida')

const PrecioSchema = z.number()
  .positive('Precio debe ser positivo')
  .multipleOf(0.01, 'Máximo 2 decimales')

const EnteroSchema = z.number().int('Debe ser un número entero')

const RangoSchema = z.number()
  .gte(0, 'Mínimo 0')  // Greater than or equal
  .lte(100, 'Máximo 100')  // Less than or equal

Opcionales y valores por defecto

const PerfilSchema = z.object({
  nombre: z.string(),
  apellido: z.string().optional(),  // Puede no existir
  edad: z.number().nullable(),      // Puede ser null
  pais: z.string().default('México'),  // Valor por defecto
})

PerfilSchema.parse({
  nombre: 'Juan',
  edad: null,
  // apellido no existe → OK
  // pais no existe → se pone 'México' automáticamente
})

Validación de formularios

Ejemplo: Formulario de registro

import { z } from 'zod'

const RegistroSchema = z.object({
  email: z.string()
    .email('Email inválido')
    .toLowerCase(),  // Convierte a minúsculas
  
  password: z.string()
    .min(8, 'Mínimo 8 caracteres')
    .regex(/[A-Z]/, 'Debe contener al menos una mayúscula')
    .regex(/[0-9]/, 'Debe contener al menos un número'),
  
  confirmarPassword: z.string(),
  
  nombre: z.string()
    .min(2, 'Nombre muy corto')
    .max(50, 'Nombre muy largo')
    .trim(),  // Elimina espacios al inicio/final
  
  edad: z.number()
    .min(18, 'Debes ser mayor de edad'),
  
  terminos: z.boolean()
    .refine((val) => val === true, {
      message: 'Debes aceptar los términos'
    }),
}).refine((data) => data.password === data.confirmarPassword, {
  message: 'Las contraseñas no coinciden',
  path: ['confirmarPassword'],  // Asigna el error a este campo
})

// Usar en tu componente
'use client'

import { useState } from 'react'

export default function FormularioRegistro() {
  const [errores, setErrores] = useState({})

  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault()
    
    const formData = new FormData(e.currentTarget)
    const datos = {
      email: formData.get('email'),
      password: formData.get('password'),
      confirmarPassword: formData.get('confirmarPassword'),
      nombre: formData.get('nombre'),
      edad: Number(formData.get('edad')),
      terminos: formData.get('terminos') === 'on',
    }
    
    try {
      // Validar
      const datosValidos = RegistroSchema.parse(datos)
      
      // Si llega aquí, los datos son válidos
      await registrarUsuario(datosValidos)
      alert('Registro exitoso')
      
    } catch (error) {
      if (error instanceof z.ZodError) {
        // Convertir errores a objeto
        const erroresFormato = {}
        error.errors.forEach((err) => {
          erroresFormato[err.path[0]] = err.message
        })
        setErrores(erroresFormato)
      }
    }
  }
  
  return (
    <form onSubmit={handleSubmit}>
      <div>
        <input name="email" type="email" placeholder="Email" />
        {errores.email && <p className="text-red-500">{errores.email}</p>}
      </div>
      
      <div>
        <input name="password" type="password" placeholder="Contraseña" />
        {errores.password && <p className="text-red-500">{errores.password}</p>}
      </div>
      
      <div>
        <input name="confirmarPassword" type="password" placeholder="Confirmar contraseña" />
        {errores.confirmarPassword && <p className="text-red-500">{errores.confirmarPassword}</p>}
      </div>
      
      <div>
        <input name="nombre" placeholder="Nombre" />
        {errores.nombre && <p className="text-red-500">{errores.nombre}</p>}
      </div>
      
      <div>
        <input name="edad" type="number" placeholder="Edad" />
        {errores.edad && <p className="text-red-500">{errores.edad}</p>}
      </div>
      
      <div>
        <label>
          <input name="terminos" type="checkbox" />
          Acepto los términos y condiciones
        </label>
        {errores.terminos && <p className="text-red-500">{errores.terminos}</p>}
      </div>
      
      <button type="submit">Registrarse</button>
    </form>
  )
}

Con React Hook Form

Zod se integra perfectamente con React Hook Form:

npm install react-hook-form @hookform/resolvers
'use client'

import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'

const RegistroSchema = z.object({
  email: z.string().email('Email inválido'),
  password: z.string().min(8, 'Mínimo 8 caracteres'),
  nombre: z.string().min(2, 'Nombre muy corto'),
})

type RegistroForm = z.infer<typeof RegistroSchema>

export default function FormularioRegistro() {
  const { register, handleSubmit, formState: { errors } } = useForm<RegistroForm>({
    resolver: zodResolver(RegistroSchema),
  })
  
  function onSubmit(datos: RegistroForm) {
    console.log('Datos válidos:', datos)
    // Enviar al servidor
  }
  
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <input {...register('email')} placeholder="Email" />
        {errors.email && <p className="text-red-500">{errors.email.message}</p>}
      </div>
      
      <div>
        <input {...register('password')} type="password" placeholder="Contraseña" />
        {errors.password && <p className="text-red-500">{errors.password.message}</p>}
      </div>
      
      <div>
        <input {...register('nombre')} placeholder="Nombre" />
        {errors.nombre && <p className="text-red-500">{errors.nombre.message}</p>}
      </div>
      
      <button type="submit">Registrarse</button>
    </form>
  )
}

Validación de APIs

Server Actions con Zod

'use server'

import { z } from 'zod'
import { db } from '@/lib/db'

const ProductoSchema = z.object({
  nombre: z.string().min(3).max(100),
  precio: z.number().positive(),
  descripcion: z.string().max(500),
  categoriaId: z.string().uuid(),
})

export async function crearProducto(formData: FormData) {
  const datos = {
    nombre: formData.get('nombre'),
    precio: Number(formData.get('precio')),
    descripcion: formData.get('descripcion'),
    categoriaId: formData.get('categoriaId'),
  }
  
  try {
    // Validar
    const productoValido = ProductoSchema.parse(datos)
    
    // Crear en DB
    const producto = await db.producto.create({
      data: productoValido
    })
    
    return { success: true, producto }
    
  } catch (error) {
    if (error instanceof z.ZodError) {
      return { 
        success: false, 
        errores: error.errors.map(e => ({
          campo: e.path[0],
          mensaje: e.message
        }))
      }
    }
    
    return { success: false, error: 'Error desconocido' }
  }
}

API Routes

// app/api/productos/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'

const ProductoSchema = z.object({
  nombre: z.string(),
  precio: z.number().positive(),
})

export async function POST(request: NextRequest) {
  try {
    const body = await request.json()
    
    // Validar
    const productoValido = ProductoSchema.parse(body)
    
    // Crear producto
    const producto = await db.producto.create({
      data: productoValido
    })
    
    return NextResponse.json({ success: true, producto })
    
  } catch (error) {
    if (error instanceof z.ZodError) {
      return NextResponse.json(
        { success: false, errores: error.errors },
        { status: 400 }
      )
    }
    
    return NextResponse.json(
      { success: false, error: 'Error del servidor' },
      { status: 500 }
    )
  }
}

Tipos de TypeScript automáticos

Zod genera tipos de TypeScript automáticamente desde tus schemas:

import { z } from 'zod'

const UsuarioSchema = z.object({
  id: z.string(),
  email: z.string().email(),
  nombre: z.string(),
  edad: z.number().optional(),
})

// Extraer el tipo
type Usuario = z.infer<typeof UsuarioSchema>

// Equivalente a:
// type Usuario = {
//   id: string
//   email: string
//   nombre: string
//   edad?: number
// }

// Usar el tipo
function procesarUsuario(usuario: Usuario) {
  console.log(usuario.email)  // TypeScript sabe que es string
  console.log(usuario.edad)   // TypeScript sabe que es number | undefined
}

Ventaja: Defines el schema una sola vez y obtienes:

  1. Validación en runtime
  2. Tipos de TypeScript

Transformaciones

Zod puede transformar datos mientras valida:

const PrecioSchema = z.string()
  .transform((val) => parseFloat(val))  // String → Number
  .pipe(z.number().positive())          // Valida que sea positivo

PrecioSchema.parse('25.50')  // Retorna: 25.50 (number)

// Fechas
const FechaSchema = z.string()
  .transform((str) => new Date(str))
  .pipe(z.date())

FechaSchema.parse('2024-01-15')  // Retorna: Date object

// Normalizar email
const EmailSchema = z.string()
  .email()
  .toLowerCase()
  .trim()

EmailSchema.parse('  JUAN@EXAMPLE.COM  ')  // Retorna: 'juan@example.com'

Validaciones personalizadas

Con .refine()

const PasswordSchema = z.string()
  .min(8)
  .refine(
    (password) => /[A-Z]/.test(password),
    { message: 'Debe contener una mayúscula' }
  )
  .refine(
    (password) => /[0-9]/.test(password),
    { message: 'Debe contener un número' }
  )

// Validar múltiples campos
const FormSchema = z.object({
  password: z.string(),
  confirmarPassword: z.string(),
}).refine(
  (data) => data.password === data.confirmarPassword,
  {
    message: 'Las contraseñas no coinciden',
    path: ['confirmarPassword'],
  }
)

Validación async

const EmailUnicoSchema = z.string().email().refine(
  async (email) => {
    // Verificar en la base de datos
    const existe = await db.usuario.findUnique({
      where: { email }
    })
    return !existe  // Retorna true si NO existe
  },
  { message: 'Este email ya está registrado' }
)

// Usar con parseAsync
await EmailUnicoSchema.parseAsync('juan@example.com')

Uniones y discriminadores

Uniones (or)

// Acepta string O number
const IdSchema = z.union([z.string(), z.number()])

IdSchema.parse('123')  // ✅ OK
IdSchema.parse(123)    // ✅ OK
IdSchema.parse(true)   // ❌ Error

Discriminated Unions

Para diferentes tipos de objetos con un campo discriminador:

const NotificacionSchema = z.discriminatedUnion('tipo', [
  z.object({
    tipo: z.literal('email'),
    email: z.string().email(),
    asunto: z.string(),
  }),
  z.object({
    tipo: z.literal('sms'),
    telefono: z.string(),
    mensaje: z.string(),
  }),
  z.object({
    tipo: z.literal('push'),
    token: z.string(),
    titulo: z.string(),
  }),
])

// Usar
function enviarNotificacion(notif: z.infer<typeof NotificacionSchema>) {
  switch (notif.tipo) {
    case 'email':
      // TypeScript sabe que tiene email y asunto
      console.log(notif.email, notif.asunto)
      break
    case 'sms':
      // TypeScript sabe que tiene telefono y mensaje
      console.log(notif.telefono, notif.mensaje)
      break
    case 'push':
      // TypeScript sabe que tiene token y titulo
      console.log(notif.token, notif.titulo)
      break
  }
}

Casos de uso comunes

Variables de entorno

// lib/env.ts
import { z } from 'zod'

const EnvSchema = z.object({
  DATABASE_URL: z.string().url(),
  API_KEY: z.string().min(1),
  NODE_ENV: z.enum(['development', 'production', 'test']),
  PORT: z.string().transform((val) => parseInt(val)),
})

// Validar al inicio de la app
export const env = EnvSchema.parse(process.env)

// Usar en tu app
import { env } from '@/lib/env'

console.log(env.DATABASE_URL)  // TypeScript sabe que es string
console.log(env.PORT)          // TypeScript sabe que es number

Validar respuestas de APIs externas

const GithubUserSchema = z.object({
  login: z.string(),
  id: z.number(),
  avatar_url: z.string().url(),
  name: z.string().nullable(),
  email: z.string().email().nullable(),
})

async function obtenerUsuarioGithub(username: string) {
  const response = await fetch(`https://api.github.com/users/${username}`)
  const data = await response.json()
  
  // Validar que la API retorna lo que esperamos
  const usuario = GithubUserSchema.parse(data)
  
  return usuario  // Tipo seguro
}

Configuración de aplicación

const ConfigSchema = z.object({
  app: z.object({
    nombre: z.string(),
    version: z.string(),
  }),
  features: z.object({
    analytics: z.boolean().default(true),
    darkMode: z.boolean().default(false),
    beta: z.boolean().default(false),
  }),
  limites: z.object({
    maxUploadSize: z.number().positive(),
    maxRequestsPerMinute: z.number().int().positive(),
  }),
})

// Cargar desde archivo JSON
import configFile from './config.json'

export const config = ConfigSchema.parse(configFile)

Query params y search params

// app/productos/page.tsx
import { z } from 'zod'

const SearchParamsSchema = z.object({
  categoria: z.string().optional(),
  precioMin: z.string().transform(Number).optional(),
  precioMax: z.string().transform(Number).optional(),
  orden: z.enum(['precio-asc', 'precio-desc', 'nombre']).optional(),
  pagina: z.string().transform(Number).default('1'),
})

export default async function ProductosPage({ 
  searchParams 
}: { 
  searchParams: { [key: string]: string | string[] | undefined } 
}) {
  // Validar y parsear
  const params = SearchParamsSchema.parse(searchParams)
  
  // Ahora params tiene tipos correctos
  console.log(params.precioMin)  // number | undefined
  console.log(params.pagina)     // number (siempre existe por default)
  
  const productos = await db.producto.findMany({
    where: {
      categoria: params.categoria,
      precio: {
        gte: params.precioMin,
        lte: params.precioMax,
      }
    },
    orderBy: params.orden === 'precio-asc' ? { precio: 'asc' } : undefined,
    skip: (params.pagina - 1) * 20,
    take: 20,
  })
  
  return <div>{/* productos */}</div>
}

Errores comunes y soluciones

Error: Expected X, received Y

const schema = z.number()
schema.parse('123')  // ❌ Error: Expected number, received string

Solución: Transforma el tipo primero

const schema = z.string().transform(Number)
schema.parse('123')  // ✅ OK: retorna 123

Error: Required (campo faltante)

const schema = z.object({
  nombre: z.string(),
  edad: z.number(),
})

schema.parse({ nombre: 'Juan' })  // ❌ Error: edad is required

Solución: Marca el campo como opcional

const schema = z.object({
  nombre: z.string(),
  edad: z.number().optional(),
})

Validar pero no lanzar error

// parse() lanza error si falla
try {
  const resultado = schema.parse(datos)
} catch (error) {
  // manejar error
}

// safeParse() NO lanza error
const resultado = schema.safeParse(datos)

if (resultado.success) {
  console.log(resultado.data)  // Datos válidos
} else {
  console.log(resultado.error)  // Errores de validación
}

Mejores prácticas

1. Define schemas cerca de donde los usas

// ✅ Bueno: Schema junto a su función
const LoginSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
})

export async function login(datos: unknown) {
  const { email, password } = LoginSchema.parse(datos)
  // ...
}

2. Reutiliza schemas comunes

// lib/schemas.ts
export const EmailSchema = z.string().email().toLowerCase()
export const PasswordSchema = z.string().min(8)
export const UUIDSchema = z.string().uuid()

// Usar en múltiples lugares
import { EmailSchema, PasswordSchema } from '@/lib/schemas'

const LoginSchema = z.object({
  email: EmailSchema,
  password: PasswordSchema,
})

const RegistroSchema = z.object({
  email: EmailSchema,
  password: PasswordSchema,
  nombre: z.string(),
})

3. Mensajes de error claros

// ❌ Mal: Mensajes genéricos
const schema = z.string().min(8)

// ✅ Bien: Mensajes específicos
const schema = z.string().min(8, 'La contraseña debe tener al menos 8 caracteres')

4. Valida en el servidor siempre

// ❌ Mal: Solo validar en el cliente
'use client'
function FormularioCliente() {
  const validar = (datos) => schema.parse(datos)
  // Alguien puede bypassear esto desde DevTools
}

// ✅ Bien: Validar en Server Action
'use server'
export async function crearUsuario(formData: FormData) {
  const datosValidos = schema.parse(formData)  // Validación segura
  await db.usuario.create({ data: datosValidos })
}

5. Usa z.infer para tipos

// ❌ Mal: Duplicar tipos
const UserSchema = z.object({
  id: z.string(),
  email: z.string(),
})

type User = {
  id: string
  email: string
}  // Mantenimiento duplicado

// ✅ Bien: Inferir tipo del schema
const UserSchema = z.object({
  id: z.string(),
  email: z.string(),
})

type User = z.infer<typeof UserSchema>  // Un solo lugar de verdad

Comparación con otras opciones

CaracterísticaZodYupJoi
TypeScript first
Sin dependencias
Inferencia de tipos⚠️ Limitada
Bundle size~8kb~13kb~146kb
Transformaciones
Async validation

Recursos adicionales

  • Documentación oficial: zod.dev
  • Playground: Prueba Zod en tu navegador
  • Integración con tRPC: Validación automática de APIs
  • Zod to JSON Schema: Convierte schemas de Zod a JSON Schema

Resumen

Zod en pocas palabras:

  • Valida datos en runtime
  • Genera tipos de TypeScript automáticamente
  • Mensajes de error personalizables
  • Sin dependencias, muy ligero
  • Perfecto para formularios, APIs y configuraciones

Cuándo usar Zod:

  • Validar datos de formularios
  • Validar respuestas de APIs externas
  • Validar datos antes de guardar en DB
  • Validar variables de entorno
  • Cualquier dato que venga del exterior

Patrón básico:

  1. Define el schema
  2. Valida con .parse() o .safeParse()
  3. Usa los datos validados
  4. Maneja errores apropiadamente

Con Zod, tus aplicaciones son más seguras, más fáciles de mantener, y los errores se detectan antes de que lleguen a producción. 🛡️