Transformaciones de Zod
Da forma, realiza coerción y añade lógica de validación personalizada con transform, refine, superRefine, preprocess y pipe.
Receta
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.
Ejemplo de Trabajo
"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:
transformpara normalizar el nombre de usuario y el correo electrónicosuperRefinepara validación de contraseña con múltiples reglaspreprocesspara convertir la edad de string a número.refinea nivel de objeto para validación entre campos (coincidencia de contraseña)
Análisis Profundo
Cómo Funciona
- transform se ejecuta después de que pase la validación y asigna la salida a un valor nuevo (puede cambiar el tipo)
- refine añade una comprobación de predicado; devolver
falsecrea un único problema - superRefine proporciona acceso completo a
RefinementCtxpara que puedas añadir múltiples problemas - preprocess ejecuta una función en la entrada sin procesar antes de cualquier validación de esquema
- pipe pasa la salida de un esquema como entrada a otro — útil para transformaciones de múltiples pasos
- Las transformaciones cambian el tipo de salida, lo que significa que
z.inputyz.outputpueden diferir
Variaciones
Refine 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),
});Notas de TypeScript
// 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;
});Trampas
-
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.preprocesses un gancho de propósito general;z.coerce.*es un atajo paraz.preprocess(Constructor, ...). Prefierez.coercepara 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 usaparseAsyncosafeParseAsync. -
Ruta refine a nivel de objeto — Si omites
pathen un.refine()a nivel de objeto, el error aparece enformErrorsen lugar defieldErrors. Solución: Siempre especificapath: ["fieldName"]para refinamientos a nivel de objeto.
Alternativas
| 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 |
Preguntas Frecuentes
¿Cuál es la diferencia entre transform, refine y superRefine?
transformcambia el valor de salida (y posiblemente su tipo)refineañade un predicado de validación personalizada única (devuelve true/false)superRefineproporciona acceso completo aRefinementCtxpara que puedas añadir múltiples problemas
¿Cuándo debes usar preprocess vs z.coerce?
z.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.
¿Cómo funciona pipe y cuándo es útil?
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()));¿Cómo normalizas la entrada del usuario como trimming de espacios en blanco y minúsculas?
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.
¿Cómo validas una contraseña con múltiples reglas usando superRefine?
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" });
});¿Cómo haces validación entre campos (p. ej., confirmación de contraseña)?
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"],
});¿Cómo realizas validación asincronismo como verificar si existe un correo?
const UniqueEmail = z.string().email().refine(
async (email) => !(await db.user.findUnique({ where: { email } })),
{ message: "Correo electrónico ya registrado" }
);
// Debe usar parseAsync o safeParseAsyncTrampa: ¿Se ejecuta refine si falla la validación base?
No. 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.
Trampa: ¿Qué sucede si llamas parse() en un esquema con refinamientos asincronos?
Lanza un error. Debes usar parseAsync() o safeParseAsync() para cualquier esquema que contenga refinamientos asincronos.
¿Por qué deberían ir los validadores antes de las transformaciones en una cadena?
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.
¿Qué son tipos marcados y cómo los creas con Zod?
const UserId = z.string().uuid().brand<"UserId">();
type UserId = z.infer<typeof UserId>;
// string & { __brand: "UserId" }
// Evita pasar strings arbitrarios donde se espera un UserIdTypeScript: ¿Cómo afectan las transformaciones a z.input vs z.output?
const 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.
TypeScript: ¿Puede superRefine estrechar un 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;
}
);Relacionado
- Conceptos Básicos de Zod — parse, safeParse, manejo de errores
- Tipos de Zod — todos los tipos incorporados
- Zod Infer — z.infer, z.input, z.output
- RHF + Zod — integración de resolver