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:
- Validación en runtime
- 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ística | Zod | Yup | Joi |
---|---|---|---|
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:
- Define el schema
- Valida con
.parse()
o.safeParse()
- Usa los datos validados
- 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. 🛡️