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:
// 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:
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
npm install zodEso 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 numberTipos 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 },
]) // OKValidaciones 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 equalOpcionales 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
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
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:
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(),
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
// 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 qué es string
console.log(usuario.edad) // TypeScript sabe qué 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 válida:
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()
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:
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) // ErrorDiscriminated 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(),
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
// 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 numberValidar 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:
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(),
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
// 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
const schema = z.number()
schema.parse('123') // Error: Expected number, received stringSolución: Transforma el tipo primero
const schema = z.string().transform(Number)
schema.parse('123') // OK: retorna 123Error: Required (campo faltante)
const schema = z.object({
nombre: z.string(),
edad: z.number(),
})
schema.parse({ nombre: 'Juan' }) // Error: edad is requiredSolució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. válida 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 })
}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
// 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 verdadComparación con otras opciones
| Caracteristica | Zod | Yup | Joi |
|---|---|---|---|
| TypeScript first | Si | No | No |
| Sin dependencias | Si | No | No |
| Inferencia de tipos | Si | Limitada | No |
| Bundle size | ~8kb | ~13kb | ~146kb |
| Transformaciones | Si | Si | Si |
| Async validation | Si | Si | Si |
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:
- Define el schema
- válida 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.
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.
Articulos relacionados
Zod Avanzado: Discriminated Unions, Transforms y Pipes
Patrones avanzados de Zod: discriminated unions, transforms, pipes, preprocess, y como validar datos complejos en TypeScript con schemas reutilizables.
tRPC + Next.js: APIs Type-Safe sin REST
Implementa tRPC en Next.js para APIs 100% type-safe. Sin schemas de API, sin fetch manual, sin types duplicados. End-to-end type safety con TypeScript.
Webhooks en Next.js: Recibe y Procesa Eventos
Implementa webhooks en Next.js para recibir eventos de Stripe, GitHub, Clerk y otros servicios. Verificación de firmas, tipado y manejo de errores.