tutoriales·10 min de lectura

Zod: Validación de Datos en TypeScript - guía Completa

Aprende a validar datos en TypeScript con Zod. guía práctica desde cero: formularios, APIs, tipos automaticos y mejores prácticas.

Zod: Validación de Datos en TypeScript

Zod es una librería de validación de datos en TypeScript que te permite definir schemas y verificar en runtime que los datos que entran a tu aplicación sean correctos. Si trabajas con formularios, APIs o cualquier dato externo, Zod te ahorra horas de debugging y previene errores en producción.

¿Por qué necesitas Zod?

Si tienes un formulario de registro sin validación, esto es lo que puede pasar:

tsx
// 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 esta vacio?
  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:

tsx
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) {
  // válida 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

bash
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:

tsx
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

tsx
// 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

tsx
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

tsx
// 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

tsx
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

tsx
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

tsx
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

Zod es especialmente útil para validar datos de formularios antes de procesarlos. Si además necesitas enviar emails transaccionales después de validar un registro, Zod te garantiza que los datos sean correctos antes de disparar el envio.

Ejemplo: Formulario de registro

tsx
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'),
 
  términos: 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')),
      términos: formData.get('términos') === '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="términos" type="checkbox" />
          Acepto los términos y condiciones
        </label>
        {errores.términos && <p className="text-red-500">{errores.términos}</p>}
      </div>
 
      <button type="submit">Registrarse</button>
    </form>
  )
}

Con React Hook Form

Zod se integra perfectamente con React Hook Form:

bash
npm install react-hook-form @hookform/resolvers
tsx
'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

tsx
'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(),
  descripción: 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')),
    descripción: formData.get('descripción'),
    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

tsx
// 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:

tsx
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 qué es string
  console.log(usuario.edad)   // TypeScript sabe qué 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 válida:

tsx
const PrecioSchema = z.string()
  .transform((val) => parseFloat(val))  // String → Number
  .pipe(z.number().positive())          // válida 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()

tsx
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

Si necesitas validaciones que dependen de llamadas asíncronas (como verificar si un email ya existe en la base de datos), puedes usar async/await con refine:

tsx
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)

tsx
// 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:

tsx
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(),
    título: z.string(),
  }),
])
 
// Usar
function enviarNotificación(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 título
      console.log(notif.token, notif.título)
      break
  }
}

Casos de uso comunes

Variables de entorno

tsx
// 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 qué es string
console.log(env.PORT)          // TypeScript sabe qué es number

Validar respuestas de APIs externas

Cuando consumes APIs externas con Fetch API o Axios, no puedes confiar en que la respuesta tenga la estructura que esperas. Zod te protege contra cambios inesperados en la API:

tsx
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

tsx
const ConfigSchema = z.object({
  app: z.object({
    nombre: z.string(),
    versión: z.string(),
  }),
  features: z.object({
    analytics: z.boolean().default(true),
    darkMode: z.boolean().default(false),
    beta: z.boolean().default(false),
  }),
  límites: 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

tsx
// app/productos/page.tsx
import { z } from 'zod'
 
const SearchParamsSchema = z.object({
  categoría: z.string().optional(),
  precioMin: z.string().transform(Number).optional(),
  precioMax: z.string().transform(Number).optional(),
  orden: z.enum(['precio-asc', 'precio-desc', 'nombre']).optional(),
  página: 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.página)     // number (siempre existe por default)
 
  const productos = await db.producto.findMany({
    where: {
      categoría: params.categoría,
      precio: {
        gte: params.precioMin,
        lte: params.precioMax,
      }
    },
    orderBy: params.orden === 'precio-asc' ? { precio: 'asc' } : undefined,
    skip: (params.página - 1) * 20,
    take: 20,
  })
 
  return <div>{/* productos */}</div>
}

Errores comunes y soluciones

Error: Expected X, received Y

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

Solución: Transforma el tipo primero

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

Error: Required (campo faltante)

tsx
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

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

Validar pero no lanzar error

tsx
// 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

tsx
// 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

tsx
// 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

tsx
// 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. válida en el servidor siempre

tsx
// 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 })
}

Validar datos es una primera línea de defensa contra input malicioso. Para una protección más completa de tu proyecto, herramientas como datahogo escanean tu código en busca de credenciales expuestas y vulnerabilidades antes de que lleguen a producción.

5. Usa z.infer para tipos

tsx
// 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

CaracteristicaZodYupJoi
TypeScript firstSiNoNo
Sin dependenciasSiNoNo
Inferencia de tiposSiLimitadaNo
Bundle size~8kb~13kb~146kb
TransformacionesSiSiSi
Async validationSiSiSi

Recursos adicionales

  • Documentación oficial de Zod: zod.dev -- referencia completa de la API, todos los métodos y opciones
  • TypeScript Handbook: typescriptlang.org/docs/handbook -- si necesitas repasar los fundamentos de TypeScript que Zod aprovecha (generics, type inference, utility types)
  • Zod GitHub: github.com/colinhacks/zod -- issues, ejemplos de la comunidad y changelog

Resumen

Zod en pocas palabras:

  • válida 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. válida 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.

#typescript#zod#validación#formularios

Preguntas frecuentes

¿Qué es Zod y para que sirve?

Zod es una librería de validación de datos para TypeScript que permite definir schemas y validar datos en runtime. Genera tipos de TypeScript automáticamente a partir de los schemas, eliminando la duplicación entre validación y tipado.

¿Cuál es la diferencia entre parse y safeParse en Zod?

parse lanza un error si la validación falla, mientras que safeParse retorna un objeto con success true/false y los datos o errores. safeParse es mejor para formularios donde quieres mostrar errores al usuario sin try/catch.

¿Cómo integrar Zod con React Hook Form?

Usa el resolver de @hookform/resolvers/zod. Define tu schema con Zod, pasalo al resolver en useForm, y React Hook Form validara automáticamente contra ese schema mostrando los errores correspondientes.