Conceptos básicos de Zod
Define esquemas, valida datos en tiempo de ejecución y maneja errores — los fundamentos de la validación type-safe en TypeScript.
Receta
Tarjeta de referencia rápida — lista para copiar y pegar.
import { z } from "zod";
// 1. Define un esquema
const UserSchema = z.object({
name: z.string().min(1, "El nombre es requerido"),
email: z.string().email("Email inválido"),
age: z.number().int().min(18, "Debe ser mayor de 18"),
});
// 2. Parse (lanza en caso de fallo)
try {
const user = UserSchema.parse({ name: "Ada", email: "ada@example.com", age: 30 });
console.log(user); // tipado como { name: string; email: string; age: number }
} catch (err) {
if (err instanceof z.ZodError) {
console.error(err.issues);
}
}
// 3. safeParse (nunca lanza)
const result = UserSchema.safeParse({ name: "", email: "bad", age: 15 });
if (!result.success) {
console.error(result.error.flatten());
} else {
console.log(result.data);
}Cuándo usarlo: Siempre que necesites validación en tiempo de ejecución de entrada de usuario, respuestas API, variables de entorno o cualquier límite de datos no confiables.
Ejemplo trabajado
"use client";
import { useState } from "react";
import { z } from "zod";
const ContactSchema = z.object({
name: z.string().min(1, "El nombre es requerido").max(100, "El nombre es muy largo"),
email: z.string().email("Por favor ingresa un email válido"),
message: z
.string()
.min(10, "El mensaje debe tener al menos 10 caracteres")
.max(1000, "El mensaje es muy largo"),
});
type ContactData = z.infer<typeof ContactSchema>;
export function ContactValidator() {
const [errors, setErrors] = useState<Record<string, string[]>>({});
const [success, setSuccess] = useState<ContactData | null>(null);
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const raw = {
name: formData.get("name"),
email: formData.get("email"),
message: formData.get("message"),
};
const result = ContactSchema.safeParse(raw);
if (!result.success) {
const flat = result.error.flatten();
setErrors(flat.fieldErrors as Record<string, string[]>);
setSuccess(null);
} else {
setErrors({});
setSuccess(result.data);
}
}
return (
<form onSubmit={handleSubmit} className="flex max-w-md flex-col gap-3">
<div>
<input name="name" placeholder="Nombre" className="w-full rounded border p-2" />
{errors.name && <p className="text-sm text-red-600">{errors.name[0]}</p>}
</div>
<div>
<input name="email" placeholder="Email" className="w-full rounded border p-2" />
{errors.email && <p className="text-sm text-red-600">{errors.email[0]}</p>}
</div>
<div>
<textarea name="message" placeholder="Mensaje" className="w-full rounded border p-2" />
{errors.message && <p className="text-sm text-red-600">{errors.message[0]}</p>}
</div>
<button type="submit" className="rounded bg-blue-600 px-4 py-2 text-white">
Validar
</button>
{success && (
<pre className="rounded bg-green-50 p-3 text-sm">{JSON.stringify(success, null, 2)}</pre>
)}
</form>
);
}Lo que esto demuestra:
- Definición de esquema con validadores encadenados
safeParsepara validación sin lanzar excepcionesflatten()para obtener mensajes de error a nivel de campoz.inferpara derivar tipos TypeScript desde esquemas
Análisis profundo
Cómo funciona
- Los esquemas Zod son objetos inmutables — cada método devuelve una nueva instancia de esquema
parse()devuelve los datos validados o lanza unZodErrorque contiene un arrayissuessafeParse()devuelve una unión discriminada:{ success: true, data }o{ success: false, error }ZodError.flatten()agrupa errores enformErrors(raíz) yfieldErrors(arrays por campo)ZodError.format()devuelve un objeto anidado que coincide con la forma del esquema, útil para objetos profundamente anidados- Todos los esquemas eliminan claves desconocidas de objetos por defecto (usa
.passthrough()o.strict()para cambiar)
Variaciones
Mapas de error personalizados:
const schema = z.string({
required_error: "Este campo es requerido",
invalid_type_error: "Se esperaba un string",
}).min(1, { message: "No puede estar vacío" });Mapa de error global:
z.setErrorMap((issue, ctx) => {
if (issue.code === z.ZodIssueCode.too_small) {
return { message: `La longitud mínima es ${issue.minimum}` };
}
return { message: ctx.defaultError };
});Errores aplanados vs formateados:
const result = schema.safeParse(data);
if (!result.success) {
// Aplanado — excelente para formularios simples
result.error.flatten();
// { formErrors: string[], fieldErrors: { name?: string[], email?: string[] } }
// Formateado — excelente para objetos anidados
result.error.format();
// { name: { _errors: string[] }, address: { city: { _errors: string[] } } }
}Notas de TypeScript
// El tipo inferido coincide exactamente con el esquema
type User = z.infer<typeof UserSchema>;
// { name: string; email: string; age: number }
// ZodError es genérico — puedes tiparlo
const result = UserSchema.safeParse(data);
if (!result.success) {
const err: z.ZodError<User> = result.error;
}
// Usa z.ZodType para aceptar cualquier esquema como parámetro
function validate<T>(schema: z.ZodType<T>, data: unknown): T {
return schema.parse(data);
}Gotchas
-
parsevssafeParseen server actions — Usarparsedentro de un server action lanzará un error no manejado. Solución: Siempre usasafeParseen server actions y devuelve errores estructurados al cliente. -
Coerción de strings desde FormData —
FormData.get()devuelvestring | File | null, pero tu esquema esperanumber. Solución: Usaz.coerce.number()oz.string().pipe(z.coerce.number())cuando análices datos de formulario. -
Eliminación silenciosa de claves desconocidas —
z.object()elimina claves extra por defecto. Si las necesitas, usa.passthrough(). Si quieres rechazarlas, usa.strict(). -
Localidad del mensaje de error — Los mensajes de error de Zod son en inglés por defecto. Solución: Usa un
errorMappersonalizado para i18n.
Alternativas
| Alternativa | Úsalo cuando | No lo uses cuando |
|---|---|---|
| Yup | Quieres una librería de esquema nativa de Formik con una API similar | Necesitas inferencia de tipos TypeScript de primera categoría |
| Valibot | Necesitas el tamaño de bundle más pequeño posible | Depende de las integraciones del ecosistema extenso de Zod |
| ArkType | Quieres validación a nivel de tipo con overhead de tiempo de ejecución cercano a cero | Necesitas soporte y ejemplos de comunidad generalizados |
| Validación manual | Verificaciones puntuales con lógica trivial | Tienes más de 2-3 campos u objetos anidados |
Ejemplo del mundo real
De una aplicación SaaS Next.js 15 / React 19 en producción (SystemsArchitect.io).
// Ejemplo de producción: esquema Banner con defaults, nullable y validación entre campos
// Archivo: src/schemas/banner.ts
import { z } from 'zod';
export const BannerSchema = z.object({
title: z.string().min(1, 'El título es requerido').max(100),
subtitle: z.string().optional().default(''),
imageUrl: z.string().url('Debe ser una URL válida').nullable(),
linkUrl: z.string().url('Debe ser una URL válida').optional(),
linkText: z.string().optional().default('Más información'),
isActive: z.boolean().default(true),
startDate: z.coerce.date(),
endDate: z.coerce.date().nullable(),
priority: z.number().int().min(0).max(100).default(50),
}).refine(
(data) => {
if (data.endDate && data.startDate) {
return data.endDate > data.startDate;
}
return true;
},
{
message: 'La fecha de fin debe ser después de la fecha de inicio',
path: ['endDate'],
}
);
// Extrae el tipo TypeScript del esquema
export type Banner = z.infer<typeof BannerSchema>;
// {
// title: string;
// subtitle: string; // defaults a ''
// imageUrl: string | null; // nullable
// linkUrl?: string; // optional, sin default
// linkText: string; // defaults a 'Más información'
// isActive: boolean; // defaults a true
// startDate: Date;
// endDate: Date | null;
// priority: number; // defaults a 50
// }Lo que esto demuestra en producción:
.optional()significa que el campo puede serundefined(omitido de la entrada)..nullable()significa que puede ser explícitamentenull. Estos son diferentes:imageUrldebe estar presente pero puede sernull, mientras quelinkUrlpuede omitirse completamente..optional().default('')hace que un campo sea opcional en la entrada pero garantiza un valor en la salida. Después de analizar,subtitlees siempre unstring, nuncaundefined..refine()permite validación entre campos quez.string()oz.date()solo no pueden expresar. La opciónpath: ['endDate']adjunta el mensaje de error al campo correcto en las vistas de errores del formulario.z.coerce.date()convierte entradas de string (como"2025-01-15"desde una entrada de fecha HTML o payload JSON) en objetosDateautomáticamente. Sin coerción, pasar un string a un esquemaz.date()fallaría.z.infer<typeof BannerSchema>extrae el tipo TypeScript del esquema. Esta es la única fuente de verdad. Nunca escribes manualmente una interfaz coincidente, lo cual elimina desviación entre validación y tipos.- El esquema maneja tanto validación de solicitud API (server actions) como validación de formulario (del lado del cliente) con la misma definición. Un esquema, dos contextos.
Preguntas frecuentes
¿Cuál es la diferencia entre parse() y safeParse()?
parse()devuelve los datos validados o lanza unZodErrorsafeParse()nunca lanza; devuelve{ success: true, data }o{ success: false, error }- Usa
safeParseen server actions y en cualquier lugar donde necesites manejar errores elegantemente
¿Cómo obtienes mensajes de error a nivel de campo de un ZodError?
const result = schema.safeParse(data);
if (!result.success) {
const flat = result.error.flatten();
// flat.fieldErrors = { name?: string[], email?: string[] }
}¿Cuál es la diferencia entre flatten() y format() en ZodError?
flatten()devuelve{ formErrors: string[], fieldErrors: { [key]: string[] } }-- mejor para formularios simplesformat()devuelve un objeto anidado que coincide con la forma del esquema con arrays_errors-- mejor para objetos profundamente anidados
¿Cómo z.infer deriva un tipo TypeScript desde un esquema?
const UserSchema = z.object({
name: z.string(),
age: z.number(),
});
type User = z.infer<typeof UserSchema>;
// { name: string; age: number }¿Qué hace z.object() con claves desconocidas por defecto?
Las elimina silenciosamente. Usa .passthrough() para mantener claves extra o .strict() para rechazarlas con un error.
¿Cómo manejas campos de formulario que envían strings pero tu esquema espera números?
Usa z.coerce.number() que llama a Number(value) antes de validar, convirtiendo el string a número automáticamente.
¿Cuál es la diferencia entre .optional(), .nullable(), y .nullish()?
.optional()permiteundefined(el tipo se convierte enT | undefined).nullable()permitenull(el tipo se convierte enT | null).nullish()permite ambos (el tipo se convierte enT | null | undefined)
¿Cómo estableces un mapa de error global personalizado para i18n?
z.setErrorMap((issue, ctx) => {
if (issue.code === z.ZodIssueCode.too_small) {
return { message: `La longitud mínima es ${issue.minimum}` };
}
return { message: ctx.defaultError };
});Gotcha: ¿Por qué usar parse() en un Server Action causa errores no manejados?
parse() lanza un ZodError en caso de fallo, que se convierte en un error de servidor no manejado. Siempre usa safeParse() en server actions y devuelve errores estructurados al cliente.
Gotcha: ¿Por qué z.coerce.date() acepta números arbitrarios?
z.coerce.date() llama a new Date(value), así que acepta timestamps y números aleatorios. Si solo quieres strings de fecha ISO, usa z.string().datetime() en su lugar.
¿Qué hace .refine() en un esquema de objeto?
Agrega validación entre campos. Proporcionas un predicado que recibe el objeto completamente analizado y devuelve true/false. Especifica path: ["fieldName"] para adjuntar el error a un campo específico.
TypeScript: ¿Cómo aceptas cualquier esquema Zod como parámetro de función?
function validate<T>(schema: z.ZodType<T>, data: unknown): T {
return schema.parse(data);
}TypeScript: ¿Puedes tipar un ZodError con el tipo inferido del esquema?
Sí. z.ZodError<User> te da un error tipado cuyos issues hacen referencia a los campos de User. Esto es útil para utilidades de manejo de errores type-safe.
Relacionado
- Zod Types — strings, numbers, arrays, objects, enums, unions
- Zod Transforms — transform, refine, superRefine, preprocess
- Zod Infer — deriving TypeScript types from schemas
- RHF + Zod — integrating Zod with react-hook-form