Transformaciones de Zod
Da forma, realiza coerción y añade lógica de validación personalizada con transform, refine, superRefine, preprocess y pipe.
Busca en todas las páginas de la documentación
Da forma, realiza coerción y añade lógica de validación personalizada con transform, refine, superRefine, preprocess y pipe.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Tarjeta de referencia rápida - lista para copiar y pegar.
import { z } from "zod";
// transform - cambia el valor de salida
const Trimmed = z.string().transform((s) => s.trim());
const Lower = z.string().transform((s) => s.toLowerCase());
// refine - predicado de validación personalizada
const EvenNumber = z.number().refine((n) => n % 2 === 0, {
message: "Debe ser un número par",
});
// superRefine - múltiples problemas, control total
const PasswordSchema = z.string().superRefine((val, ctx) => {
if (val.length < 8) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Al menos 8 caracteres" });
}
if (!/[A-Z]/.test(val)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Al menos una letra mayúscula" });
}
if (!/\d/.test(val)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Al menos un dígito" });
}
});
// preprocess - realiza coerción antes de la validación
const CoercedNumber = z.preprocess((val) => Number(val), z.number().positive());
// pipe - encadena esquemas juntos
const StringToNumber = z.string().pipe(z.coerce.number().int().positive());Cuándo usarlo: Cuando la validación de tipo básica no es suficiente - necesitas normalizar datos, aplicar reglas de negocio o encadenar transformaciones.
"use client";
import { useState } from "react";
import { z } from "zod";
const SignupSchema = z
.object({
username: z
.string()
.min(3)
.max(20)
.regex(/^[a-zA-Z0-9_]+$/, "Solo letras, números y guiones bajos")
.transform((s) => s.toLowerCase()),
email: z.string().email().transform((s) => s.toLowerCase().trim()),
password: z.string().superRefine((val, ctx) => {
if (val.length < 8) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Al menos 8 caracteres" });
}
if (!/[A-Z]/.test(val)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Necesita una letra mayúscula" });
}
if (!/[0-9]/.test(val)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Necesita un dígito" });
}
}),
confirmPassword: z.string(),
age: z.preprocess((val) => Number(val), z.number().int().min(13, "Debe tener 13+")),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Las contraseñas no coinciden",
path: ["confirmPassword"],
});
type SignupInput = z.input<typeof SignupSchema>;
type SignupOutput = z.output<typeof SignupSchema>;
export function SignupForm() {
const [output, setOutput] = useState<string>("");
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const fd = new FormData(e.currentTarget);
const raw = Object.fromEntries(fd);
const result = SignupSchema.safeParse(raw);
if (result.success) {
setOutput("Válido: " + JSON.stringify(result.data, null, 2));
} else {
setOutput(result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("\n"));
}
}
return (
<form onSubmit={handleSubmit} className="flex max-w-md flex-col gap-3">
<input name="username" placeholder="Nombre de usuario" className="rounded border p-2" />
<input name="email" placeholder="Correo electrónico" className="rounded border p-2" />
<input name="password" type="password" placeholder="Contraseña" className="rounded border p-2" />
<input name="confirmPassword" type="password" placeholder="Confirmar" className="rounded border p-2" />
<input name="age" placeholder="Edad" type="number" className="rounded border p-2" />
<button type="submit" className="rounded bg-blue-600 px-4 py-2 text-white">Registrarse</button>
{output && <pre className="rounded bg-gray-50 p-3 text-sm whitespace-pre-wrap">{output}</pre>}
</form>
);
}Lo que esto demuestra:
transform para normalizar el nombre de usuario y el correo electrónicosuperRefine para validación de contraseña con múltiples reglaspreprocess para convertir la edad de string a número.refine a nivel de objeto para validación entre campos (coincidencia de contraseña)false crea un único problemaRefinementCtx para que puedas añadir múltiples problemasz.input y z.output pueden diferirRefine asincronismo para verificaciones del lado del servidor:
const UniqueEmail = z.string().email().refine(
async (email) => {
const exists = await db.user.findUnique({ where: { email } });
return !exists;
},
{ message: "Correo electrónico ya registrado" }
);
// Debe usar parseAsync / safeParseAsync
const result = await UniqueEmail.safeParseAsync("test@example.com");Encadenamiento de transformaciones:
const CsvToNumbers = z
.string()
.transform((s) => s.split(","))
.transform((arr) => arr.map(Number))
.pipe(z.array(z.number().finite()));Tipos marcados con refine:
const UserId = z.string().uuid().brand<"UserId">();
type UserId = z.infer<typeof UserId>; // string & { __brand: "UserId" }
function getUser(id: UserId) { /* ... */ }
// getUser("random-string") - error de compilación
// getUser(UserId.parse("550e...")) - funcionaCanalización de default + transform:
const Config = z.object({
port: z.coerce.number().default(3000),
host: z.string().default("localhost").transform((h) => h.toLowerCase()),
debug: z.preprocess((v) => v === "true" || v === true, z.boolean()).default(false),
});// Cuando un esquema tiene transformaciones, los tipos de entrada y salida difieren
const S = z.string().transform((s) => s.length);
type SInput = z.input<typeof S>; // string
type SOutput = z.output<typeof S>; // number
type SInfer = z.infer<typeof S>; // number (igual que output)
// superRefine puede estrechar tipos
const NonEmpty = z.array(z.string()).superRefine((arr, ctx): arr is [string, ...string[]] => {
if (arr.length === 0) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Necesita al menos uno" });
return false;
}
return true;
});refine solo se ejecuta si la validación base pasa - Si z.string().email().refine(...) recibe un no-email, el callback refine nunca se llama. Esto es generalmente deseable pero puede sorprenderte si esperas todos los errores a la vez.
transform cambia el tipo - Después de .transform(), los validadores anteriores ven el tipo transformado. Si encadenas .min() después de .transform(), valida el valor transformado. Solución: Coloca los validadores antes de las transformaciones.
preprocess vs coerce - z.preprocess es un gancho de propósito general; z.coerce.* es un atajo para z.preprocess(Constructor, ...). Prefiere z.coerce para la conversión de tipo simple.
Los refinamientos asincronos requieren parseAsync - Llamar a .parse() en un esquema con refinamientos asincronos lanza un error. Solución: Siempre usa parseAsync o safeParseAsync.
Ruta refine a nivel de objeto - Si omites path en un .refine() a nivel de objeto, el error aparece en formErrors en lugar de fieldErrors. Solución: Siempre especifica path: ["fieldName"] para refinamientos a nivel de objeto.
| Alternativa | Usa Cuando | No Uses Cuando |
|---|---|---|
Yup .test() | Usas Formik y necesitas validadores personalizados | Quieres tipos transformados inferidos de TypeScript |
Valibot transform | Necesitas transformaciones tree-shakeable con paquete mínimo | Confías en .brand() o .pipe() específicos de Zod |
| Funciones de validación personalizada | La lógica es trivial (un campo, una comprobación) | Tienes dependencias complejas entre múltiples campos |
| Decoradores Class-validator | Prefieres validación basada en decoradores en modelos de clase | Trabajas en una base de código funcional / basada en componentes |
transform cambia el valor de salida (y posiblemente su tipo)refine añade un predicado de validación personalizada única (devuelve true/false)superRefine proporciona acceso completo a RefinementCtx para que puedas añadir múltiples problemasz.coerce.* es un atajo para conversión de tipo simple (p. ej., string a número a través de Number()). Usa z.preprocess cuando necesites lógica de coerción personalizada más allá de lo que proporciona un constructor nativo.
pipe pasa la salida de un esquema como entrada a otro. Es útil para transformaciones de múltiples pasos:
const CsvToNumbers = z.string()
.transform((s) => s.split(","))
.transform((arr) => arr.map(Number))
.pipe(z.array(z.number().finite()));const Email = z.string().email().transform((s) => s.toLowerCase().trim());
const Username = z.string().min(3).transform((s) => s.toLowerCase());Coloca los validadores antes de las transformaciones para que verifiquen la entrada sin procesar.
const Password = z.string().superRefine((val, ctx) => {
if (val.length < 8)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Al menos 8 caracteres" });
if (!/[A-Z]/.test(val))
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Necesita mayúscula" });
if (!/\d/.test(val))
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Necesita un dígito" });
});Usa .refine() en el esquema de objeto con una opción path:
schema.refine((d) => d.password === d.confirmPassword, {
message: "Las contraseñas no coinciden",
path: ["confirmPassword"],
});const UniqueEmail = z.string().email().refine(
async (email) => !(await db.user.findUnique({ where: { email } })),
{ message: "Correo electrónico ya registrado" }
);
// Debe usar parseAsync o safeParseAsyncNo. Si z.string().email().refine(...) recibe un no-email, el callback refine nunca se llama. La validación base debe pasar primero. Esto significa que no verás todos los errores a la vez si el tipo base es incorrecto.
Lanza un error. Debes usar parseAsync() o safeParseAsync() para cualquier esquema que contenga refinamientos asincronos.
Después de .transform(), los validadores anteriores ven el valor transformado. Si encadenas .min() después de .transform(), valida la salida transformada, no la entrada original.
const UserId = z.string().uuid().brand<"UserId">();
type UserId = z.infer<typeof UserId>;
// string & { __brand: "UserId" }
// Evita pasar strings arbitrarios donde se espera un UserIdconst S = z.string().transform((s) => s.length);
type SInput = z.input<typeof S>; // string
type SOutput = z.output<typeof S>; // numberLos tipos de entrada y salida divergen cuando una transformación cambia el tipo.
Sí. Usa una devolución de predicado de tipo:
const NonEmpty = z.array(z.string()).superRefine(
(arr, ctx): arr is [string, ...string[]] => {
if (arr.length === 0) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Necesita uno" });
return false;
}
return true;
}
);Revisado por Chris St. John·Última actualización: 16 jul 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥