Zod
Zod valida datos en runtime y deriva el tipo TypeScript del schema — una sola fuente de verdad en vez de escribir el interface a mano y esperar que no se desincronice con la validación real.
Instalación
Sección titulada «Instalación»pnpm add zodbun add zodnpm install zodCrear un schema
Sección titulada «Crear un schema»z.object() describe la forma de un objeto. Cada propiedad es a su vez un schema — así se anidan.
import { z } from "zod"
export const userSchema = z.object({ email: z.email("Email no válido"), age: z.number().int().min(18, "Debes ser mayor de edad"), role: z.enum(["admin", "user"]).default("user"), website: z.url().optional()})Los objetos anidan sin nada especial: una propiedad que es otro z.object() valida su propia forma completa.
const addressSchema = z.object({ street: z.string(), city: z.string()})
const userWithAddressSchema = z.object({ name: z.string(), address: addressSchema})Inferir el tipo
Sección titulada «Inferir el tipo»z.infer<typeof schema> deriva el tipo TypeScript directamente del schema. Nunca escribas el interface/type a mano al lado de un schema — se desincronizan con el tiempo y dejas de confiar en cuál es la verdad.
export type User = z.infer<typeof userSchema>// { email: string; age: number; role: "admin" | "user"; website?: string }Tipos de datos
Sección titulada «Tipos de datos»Los que se usan todo el tiempo:
z.string()z.number()z.boolean()z.date()z.array(z.string()) // string[]z.object({ id: z.string() }) // { id: string }z.enum(["admin", "user"]) // "admin" | "user"z.literal("production") // el valor exacto "production"z.union([z.string(), z.number()]) // string | numberz.record(z.string()) // Record<string, string>z.tuple([z.string(), z.number()]) // [string, number]optional(), nullable() y nullish() cambian qué “ausencia” acepta el schema — no son lo mismo:
z.string().optional() // string | undefinedz.string().nullable() // string | nullz.string().nullish() // string | null | undefinedUniones discriminadas — cuando un campo indica qué forma tiene el resto del objeto (eventos, estados directamente máquina de estado, respuestas de API con status):
const eventSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal("click"), x: z.number(), y: z.number() }), z.object({ type: z.literal("keypress"), key: z.string() })])Zod infiere cuál de las dos formas corresponde según el valor de type, con autocompletado incluido.
Validar strings
Sección titulada «Validar strings»Los formatos comunes (email, URL, UUID…) son funciones de nivel superior, no métodos encadenados sobre z.string() — así son desde Zod 4.
z.email()z.url()z.uuid()z.iso.date() // "2026-08-16"z.iso.datetime() // "2026-08-16T14:32:00Z"Los constraints genéricos (largo, patrón) sí van encadenados sobre z.string(), porque no son un formato sino una regla sobre cualquier string:
z.string().min(3).max(50)z.string().length(6) // exactamente 6 caracteresz.string().regex(/^[a-z0-9-]+$/, "Solo minúsculas, números y guiones")z.string().startsWith("https://")z.string().trim() // recorta espacios antes de validarValidar números
Sección titulada «Validar números»z.number().int()z.number().positive()z.number().nonnegative() // >= 0z.number().min(0).max(100)z.number().multipleOf(0.5) // solo múltiplos de 0.5Refinamientos custom
Sección titulada «Refinamientos custom».refine() agrega una validación que no está cubierta por los métodos nativos. El segundo argumento es el mensaje de error.
const passwordSchema = z .string() .refine( (value) => /[A-Z]/.test(value) && /[0-9]/.test(value), "La contraseña necesita al menos una mayúscula y un número" )Para validar comparando dos campos entre sí (confirmar contraseña, fecha de fin después de fecha de inicio), .refine() va sobre el objeto completo, no sobre un campo individual — y path indica en qué campo mostrar el error.
const signupSchema = z .object({ password: z.string().min(8), confirmPassword: z.string() }) .refine((data) => data.password === data.confirmPassword, { message: "Las contraseñas no coinciden", path: ["confirmPassword"] })Transformar datos
Sección titulada «Transformar datos».transform() cambia el valor después de validarlo — el tipo de salida (z.infer) refleja el resultado transformado, no el de entrada.
const trimmedLowercase = z .string() .transform((value) => value.trim().toLowerCase())
const csvToArray = z .string() .transform((value) => value.split(",").map((item) => item.trim()))Para convertir el tipo de entrada antes de validar (típico con FormData, donde todo llega como string), usa z.coerce en vez de .transform():
z.coerce.number() // "42" → 42z.coerce.boolean() // "true" → true (cualquier string no vacío es true)z.coerce.date() // "2026-08-16" → DateParsear datos
Sección titulada «Parsear datos»Tres formas de validar un valor contra un schema, según qué necesites hacer con el resultado.
userSchema.parse(data)// devuelve los datos tipados o lanza ZodError
const result = userSchema.safeParse(data)// { success: true, data } | { success: false, error }
await userSchema.parseAsync(data)// igual que parse(), pero espera refinamientos/transforms asyncsafeParse() es la que casi siempre quieres en código de aplicación: no lanza, así que no hace falta try/catch para el camino esperado de “datos inválidos”.
const result = userSchema.safeParse(formData);
if (!result.success) { console.log(result.error.flatten()); return;}
// result.data ya está tipado como UserManejo de errores
Sección titulada «Manejo de errores»error.flatten() convierte un ZodError en algo fácil de mostrar en un formulario: errores generales del schema y errores por campo, separados.
const result = userSchema.safeParse(data)
if (!result.success) { const { fieldErrors, formErrors } = result.error.flatten() // fieldErrors: { email: ["Email no válido"], age: [...] } // formErrors: errores que no pertenecen a un campo específico (los de .refine() sobre el objeto)}Para casos donde necesitas la estructura completa (objetos anidados, arrays), .format() da un árbol de errores que respeta la forma del schema en vez de aplanarlo.
Componer schemas
Sección titulada «Componer schemas»const baseUserSchema = z.object({ email: z.email(), name: z.string() })
baseUserSchema.extend({ role: z.enum(["admin", "user"]) }) // agrega camposbaseUserSchema.pick({ email: true }) // solo emailbaseUserSchema.omit({ name: true }) // todo menos namebaseUserSchema.partial() // todos los campos opcionales (útil para un PATCH)baseUserSchema.partial().required({ email: true }) // parcial, pero email sigue obligatorioValores por defecto y de respaldo
Sección titulada «Valores por defecto y de respaldo».default() rellena el valor cuando el campo no vino. .catch() rellena el valor cuando vino pero no pasó la validación, en vez de fallar todo el parse().
z.enum(["light", "dark"]).default("dark")// undefined → "dark"
z.number().catch(0)// "no-es-un-número" → 0, en vez de lanzarCasos de uso
Sección titulada «Casos de uso»Validar variables de entorno, en el arranque de la app — si falta algo, falla rápido con un mensaje claro en vez de un undefined silencioso más adelante:
const envSchema = z.object({ DATABASE_URL: z.url(), PORT: z.coerce.number().default(3000)})
export const env = envSchema.parse(process.env)Validar un formulario con FormData Utils — todo llega como string, por eso z.coerce:
const schema = z.object({ email: z.email(), age: z.coerce.number().min(18)})
const result = schema.safeParse(formToObject(form))Validar la respuesta directamente API antes de confiar en su forma — fetchJson() tipa con un genérico, pero no valida nada en runtime; Zod sí:
const apiUserSchema = z.object({ id: z.string(), email: z.string() })
const raw = await fetchJson("/api/user")const user = apiUserSchema.parse(raw) // lanza si la API cambió su forma sin avisarNovedades de Zod 4.5
Sección titulada «Novedades de Zod 4.5»Todo lo anterior sigue igual: 4.5 no rompe la forma de escribir schemas. Lo que trae es velocidad y algunas piezas que faltaban.
Compilar un schema
Sección titulada «Compilar un schema»z.compile() precompila el schema y acelera el parseo entre 3 y 9 veces en objetos, arrays y uniones.
// una sola vez, en el entry pointimport { z } from "zod"import "zod/compile"
export const userSchema = z.compile( z.object({ email: z.email(), age: z.number().int() }))También se activa sin tocar el código, arrancando con node --import zod/compile app.js.
Validar sin construir el error
Sección titulada «Validar sin construir el error»Cuando solo necesitas saber si algo es válido, z.validate() evita montar el objeto de error: hasta 16 veces más rápido que .safeParse().success sobre datos inválidos. Hay z.validateAsync() para refinamientos asíncronos.
if (!z.validate(userSchema, data)) return res.status(400).end()Úsalo en filtros y guardas. Cuando necesites decirle al usuario qué está mal, sigue siendo .safeParse().
De JSON Schema a Zod
Sección titulada «De JSON Schema a Zod»z.fromJSONSchema() hace el camino inverso a z.toJSONSchema(): convierte un JSON Schema —de una API, de OpenAPI o de una herramienta externa— en un schema de Zod.
const zodSchema = z.fromJSONSchema({ type: "object", properties: { name: { type: "string" }, age: { type: "number" } }, required: ["name", "age"]})Está marcada como experimental: no forma parte todavía de la API estable.
Piezas nuevas
Sección titulada «Piezas nuevas»| API | Para qué sirve |
|---|---|
z.creditCard() | Tarjetas de 12–19 dígitos, con checksum de Luhn, aceptando espacios y guiones |
z.deepPartial() | Hacer opcionales también los objetos anidados |
.exactPartial() | Opcional que rechaza un undefined explícito |
z.input() / z.output() | Proyectar un schema a su lado de entrada o de salida |
z.toZod<T>() | Comprobar que el schema en runtime concuerda con un tipo estático |
z.getDiscriminatedOption() | Extraer un miembro de una unión discriminada por su discriminante |
Además, z.object() acepta claves de tipo symbol y los schemas recursivos manejan datos con referencias cíclicas.
Cambios que pueden romperte algo
Sección titulada «Cambios que pueden romperte algo»No son API nueva, son correcciones de rigor: si dependías del comportamiento anterior, revísalas antes de actualizar.
z.iso.datetime()exige segundos: rechaza las fechas con precisión de minuto..min(),.max()y.length()en strings cuentan puntos de código Unicode, no unidades UTF-16 — un emoji ya no vale por dos.- Las claves de
recordy las intersecciones siguen la semántica de TypeScript. __proto__se elimina siempre de los objetos.- Validación más estricta en IPv6, ULID,
httpUrly emoji.
Nuevos idiomas
Sección titulada «Nuevos idiomas»Bengalí, kurdo central, hindi, canarés, noruego nynorsk, portugués de Brasil, eslovaco y turcomano.
Resumen
Sección titulada «Resumen»| API | Uso |
|---|---|
z.object({...}) | Definir la forma de un objeto |
z.infer<typeof schema> | Derivar el tipo TS del schema |
z.email() / z.url() / z.uuid() | Formatos de string comunes (nivel superior, no .string().email()) |
schema.parse(data) | Validar y devolver tipado, lanza ZodError si falla |
schema.safeParse(data) | Validar sin lanzar: { success, data | error } |
z.coerce.* | Convertir el tipo de entrada antes de validar (strings de FormData, env, query params) |
.refine(fn, msg) | Validación custom, incluso cruzando varios campos |
.transform(fn) | Transformar el valor después de validar |
.default(valor) | Rellenar cuando el campo no vino |
.catch(valor) | Rellenar cuando vino pero no pasó la validación |
.extend() / .pick() / .omit() / .partial() | Derivar variantes de un schema existente |
error.flatten() | Errores por campo, listos para mostrar en un formulario |
Errores comunes
Sección titulada «Errores comunes»- Si ves
.string().email()en código o tutoriales viejos: es la sintaxis de Zod 3. Desde Zod 4 los formatos son funciones de nivel superior (z.email(),z.url(),z.uuid()). La forma encadenada sigue funcionando en la mayoría de los casos, peroz.email()es la que documenta Zod hoy. - Con
FormDatatodo llega como string → usaz.coerce.*, noz.number()a secas. .optional()no es lo mismo que.nullable():undefined(campo ausente) vsnull(campo presente con valor nulo). Si la fuente de datos puede mandar cualquiera de los dos, usa.nullish().safeParse()no lanza — si usas.parse()en un boundary que no controlas (input de usuario, API externa), necesitastry/catchalrededor..refine()a nivel de objeto (para comparar dos campos) necesitapathen el segundo argumento, si no el error no queda asociado a ningún campo del formulario.- El schema es la única fuente de verdad: si escribes un
interfacea mano al lado de un schema “porque es más rápido”, con el tiempo se desincronizan y pierdes la garantía que Zod proporciona.