tutoriales·7 min de lectura

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.

Zod Avanzado: Discriminated Unions, Transforms y Pipes

Si ya usas Zod para validación básica, estos patrones avanzados te resuelven los casos reales que aparecen en producción: formularios que cambian según selecciones, datos que necesitan transformación, y schemas que se componen entre si.

Si necesitas la base primero, arranca con la guía de Zod para validación.

Discriminated Unions

El patron más útil para formularios dinámicos. Un campo "discriminador" determina que validaciones aplican:

typescript
import { z } from "zod";
 
const notificationSchema = z.discriminatedUnion("channel", [
  z.object({
    channel: z.literal("email"),
    emailAddress: z.string().email(),
    subject: z.string().min(1),
  }),
  z.object({
    channel: z.literal("sms"),
    phoneNumber: z.string().regex(/^\+\d{10,15}$/),
  }),
  z.object({
    channel: z.literal("push"),
    deviceToken: z.string().min(10),
  }),
]);
 
type Notification = z.infer<typeof notificationSchema>;
// TypeScript sabe que si channel es "email", tiene emailAddress y subject
typescript
const result = notificationSchema.safeParse({
  channel: "email",
  emailAddress: "rod@ejemplo.com",
  subject: "Hola",
});
// result.success === true
 
const bad = notificationSchema.safeParse({
  channel: "email",
  phoneNumber: "+521234567890", // Error: email necesita emailAddress, no phoneNumber
});
// result.success === false

Esto se integra directo con React Hook Form para formularios dinámicos.

Transforms

válida datos Y transformalos en un solo paso:

typescript
// String a número (común con form data)
const priceSchema = z.string()
  .transform((val) => parseFloat(val))
  .pipe(z.number().min(0, "El precio debe ser positivo"));
 
priceSchema.parse("19.99"); // 19.99 (number, no string)
priceSchema.parse("-5");    // Error: El precio debe ser positivo
 
// Normalizar email
const emailSchema = z.string()
  .email()
  .transform((email) => email.toLowerCase().trim());
 
emailSchema.parse("  ROD@Ejemplo.COM  "); // "rod@ejemplo.com"
 
// Parsear fecha de string
const dateSchema = z.string()
  .transform((str) => new Date(str))
  .pipe(z.date());
 
dateSchema.parse("2026-01-15"); // Date object

El tipo de entrada y salida pueden ser diferentes. TypeScript lo infiere automáticamente:

typescript
type Input = z.input<typeof priceSchema>;  // string
type Output = z.output<typeof priceSchema>; // number

Coerción (atajo para transforms comunes)

Zod tiene shortcuts para las transformaciones más comunes:

typescript
// En vez de z.string().transform(Number).pipe(z.number())
const age = z.coerce.number().min(18);
age.parse("25");  // 25 (number)
age.parse(25);    // 25 (number)
 
const active = z.coerce.boolean();
active.parse("true"); // true
active.parse(1);      // true
active.parse(0);      // false
 
const date = z.coerce.date();
date.parse("2026-01-15"); // Date object
date.parse(1737000000000); // Date object

útil para datos de formularios HTML donde todo llega como string.

superRefine para validaciones cruzadas

Cuando la validación de un campo depende de otro:

typescript
const transferSchema = z.object({
  fromAccount: z.string(),
  toAccount: z.string(),
  amount: z.number().positive(),
}).superRefine((data, ctx) => {
  if (data.fromAccount === data.toAccount) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: "No puedes transferir a la misma cuenta",
      path: ["toAccount"],
    });
  }
});

superRefine te da control total sobre los errores: defines el path (que campo marca en rojo), el mensaje, y puedes agregar múltiples issues.

Composición de schemas

No dupliques schemas. Componlos:

typescript
// Schema base
const userBase = z.object({
  name: z.string().min(2),
  email: z.string().email(),
});
 
// Para crear usuario (todo requerido)
const createUser = userBase.extend({
  password: z.string().min(8),
});
 
// Para actualizar (todo opcional)
const updateUser = userBase.partial();
 
// Para respuesta de API (sin password, con id)
const userResponse = userBase.extend({
  id: z.string(),
  createdAt: z.date(),
});
 
// Solo algunos campos
const userLogin = userBase.pick({ email: true }).extend({
  password: z.string(),
});

métodos de composición:

  • .extend() -- agrega campos
  • .partial() -- hace todo opcional
  • .required() -- hace todo requerido
  • .pick({ field: true }) -- solo estos campos
  • .omit({ field: true }) -- todo menos estos campos
  • .merge(otherSchema) -- combina dos schemas

Patron: schema de API con input/output

typescript
// Un solo lugar define la validación de toda una ruta
const createPostEndpoint = {
  input: z.object({
    title: z.string().min(1).max(200),
    content: z.string().min(10),
    tags: z.array(z.string()).max(5).default([]),
  }),
  output: z.object({
    id: z.string(),
    title: z.string(),
    slug: z.string(),
    createdAt: z.date(),
  }),
};
 
// Validar request
const data = createPostEndpoint.input.parse(await request.json());
 
// Validar response (útil para tests)
const response = createPostEndpoint.output.parse(result);

Este patron es la base de como tRPC funciona. Si te interesa, revisa la guía de tRPC con Next.js.

Siguiente paso

Si usas Zod con formularios, la guía de formularios dinámicos con React Hook Form y Zod aplica estos patrones en la UI. Y para la base de Zod, la guía de validación con Zod cubre todo desde cero.

#zod#typescript#validación#schemas#avanzado

Preguntas frecuentes

¿Qué es una discriminated union en Zod?

Es un schema donde el tipo de un objeto depende del valor de un campo discriminador. Por ejemplo, si type es 'email', necesitas un campo emailAddress. Si type es 'sms', necesitas phoneNumber. Zod válida automáticamente según el discriminador.

¿Qué hace z.transform()?

Transform te permite modificar los datos después de validarlos. Por ejemplo, validar que un string es un email y luego convertirlo a minusculas. El tipo de salida puede ser diferente al de entrada.

¿Cuál es la diferencia entre refine y superRefine?

refine agrega una validación custom que devuelve true o false. superRefine te da control total: puedes agregar múltiples errores, con paths y mensajes especificos. Usa refine para validaciones simples y superRefine para validaciones cruzadas entre campos.

¿Puedo reutilizar schemas de Zod?

Si. Puedes componer schemas con .merge(), .extend(), .pick(), .omit() y .partial(). Esto te permite crear un schema base y derivar variaciones sin duplicar código.