Sugerencias
← TIL
~2 min de lectura
#typescript#zod-4#architecture#patterns

Zod 4: Validaciones complejas sin volverte loco

Si tu schema de Zod tiene más de tres niveles de anidamiento con condicionales y no estás usando Uniones Discriminadas, estás construyendo una bomba de tiempo tipográfica, no un validador. La complejidad en las validaciones modernas no nace de los tipos de datos simples, sino de las reglas de negocio cruzadas que intentamos forzar en estructuras planas. Zod 4 resuelve este “Specificity Hell” de los datos mediante dos pilares fundamentales: las Uniones Discriminadas para manejar estados excluyentes y el patrón .pipe() para orquestar transformaciones secuenciales sin ensuciar la lógica de negocio.

La trampa de las uniones ambiguas#

Cuando validás un objeto que puede tener diferentes formas (por ejemplo, un pago que puede ser ‘efectivo’ o ‘tarjeta’), usar z.union obliga a Zod a probar cada esquema hasta que uno pase. Esto es lento y genera errores crípticos.

Refactor
ts
// ❌ Unión simple: Ambigua y lenta
const paymentSchema = z.union([
z.object({ method: z.literal("card"), number: z.string() }),
z.object({ method: z.literal("cash"), amount: z.number() })
]);
// ✅ Unión discriminada: Rápida y precisa
const paymentSchema = z.discriminatedUnion("method", [
z.object({ method: z.literal("card"), number: z.string() }),
z.object({ method: z.literal("cash"), amount: z.number() })
]);

Con el discriminador "method", Zod sabe exactamente qué esquema usar con solo mirar una propiedad, mejorando el rendimiento y el autocompletado de TypeScript drásticamente.

El patrón .pipe() para validación secuencial#

A veces necesitás transformar un dato antes de validarlo con un esquema complejo. En Zod 3 hacíamos malabares con .transform() y .refine(). En Zod 4, .pipe() permite encadenar esquemas completos como si fueran tuberías de producción.

pipes-pattern.ts
import { import zz } from "zod";

// 1. Esquema de entrada (input sucio)
const const InputSchema: z.ZodStringInputSchema = import zz.function string(params?: string | z.core.$ZodStringParams): z.ZodString (+1 overload)string()._ZodString<$ZodStringInternals<string>>.trim(): z.ZodStringtrim()._ZodString<$ZodStringInternals<string>>.min(minLength: number, params?: string | z.core.$ZodCheckMinLengthParams): z.ZodStringmin(1);

// 2. Esquema de negocio (estructura final)
const 
const UserSchema: z.ZodObject<{
    id: z.ZodString;
    role: z.ZodEnum<{
        admin: "admin";
        user: "user";
    }>;
}, z.core.$strip>
UserSchema
= import zz.
function object<{
    id: z.ZodString;
    role: z.ZodEnum<{
        admin: "admin";
        user: "user";
    }>;
}>(shape?: {
    id: z.ZodString;
    role: z.ZodEnum<{
        admin: "admin";
        user: "user";
    }>;
} | undefined, params?: string | {
    error?: string | z.core.$ZodErrorMap<NonNullable<z.core.$ZodIssueInvalidType<unknown> | z.core.$ZodIssueUnrecognizedKeys>> | undefined;
    message?: string | undefined | undefined;
} | undefined): z.ZodObject<{
    id: z.ZodString;
    role: z.ZodEnum<{
        admin: "admin";
        user: "user";
    }>;
}, z.core.$strip>
object
({
id: z.ZodStringid: import zz.function string(params?: string | z.core.$ZodStringParams): z.ZodString (+1 overload)string().ZodString.uuid(params?: string | z.core.$ZodCheckUUIDParams): z.ZodString
@deprecatedUse `z.uuid()` instead.
uuid
(),
role: z.ZodEnum<{
    admin: "admin";
    user: "user";
}>
role
: import zz.
enum<readonly ["admin", "user"]>(values: readonly ["admin", "user"], params?: string | z.core.$ZodEnumParams): z.ZodEnum<{
    admin: "admin";
    user: "user";
}> (+1 overload)
export enum
enum
(["admin", "user"])
}); // 3. La tubería: Parsea string -> JSON -> Valida Objeto const
const Pipeline: z.ZodPipe<z.ZodPipe<z.ZodString, z.ZodTransform<any, string>>, z.ZodObject<{
    id: z.ZodString;
    role: z.ZodEnum<{
        admin: "admin";
        user: "user";
    }>;
}, z.core.$strip>>
Pipeline
= import zz.function string(params?: string | z.core.$ZodStringParams): z.ZodString (+1 overload)string()
.ZodType<any, any, $ZodStringInternals<string>>.transform<any>(transform: (arg: string, ctx: z.core.$RefinementCtx<string>) => any): z.ZodPipe<z.ZodString, z.ZodTransform<any, string>>transform((str: stringstr) => var JSON: JSON
An intrinsic object that provides functions to convert JavaScript values to and from the JavaScript Object Notation (JSON) format.
JSON
.JSON.parse(text: string, reviver?: (this: any, key: string, value: any) => any): any
Converts a JavaScript Object Notation (JSON) string into an object.
@paramtext A valid JSON string.@paramreviver A function that transforms the results. This function is called for each member of the object. If a member contains nested objects, the nested objects are transformed before the parent object is.@throws{SyntaxError} If `text` is not valid JSON.
parse
(str: stringstr))
.
ZodType<any, any, $ZodPipeInternals<ZodString, ZodTransform<any, string>>>.pipe<z.ZodObject<{
    id: z.ZodString;
    role: z.ZodEnum<{
        admin: "admin";
        user: "user";
    }>;
}, z.core.$strip>>(target: z.ZodObject<{
    id: z.ZodString;
    role: z.ZodEnum<{
        admin: "admin";
        user: "user";
    }>;
}, z.core.$strip> | z.core.$ZodType<any, any, z.core.$ZodTypeInternals<any, any>>): z.ZodPipe<z.ZodPipe<z.ZodString, z.ZodTransform<any, string>>, z.ZodObject<{
    id: z.ZodString;
    role: z.ZodEnum<{
        admin: "admin";
        user: "user";
    }>;
}, z.core.$strip>>
pipe
(
const UserSchema: z.ZodObject<{
    id: z.ZodString;
    role: z.ZodEnum<{
        admin: "admin";
        user: "user";
    }>;
}, z.core.$strip>
UserSchema
);
const
const result: {
    id: string;
    role: "admin" | "user";
}
result
=
const Pipeline: z.ZodPipe<z.ZodPipe<z.ZodString, z.ZodTransform<any, string>>, z.ZodObject<{
    id: z.ZodString;
    role: z.ZodEnum<{
        admin: "admin";
        user: "user";
    }>;
}, z.core.$strip>>
Pipeline
.
ZodType<any, any, $ZodPipeInternals<ZodPipe<ZodString, ZodTransform<any, string>>, ZodObject<{ id: ZodString; role: ZodEnum<{ admin: "admin"; user: "user"; }>; }, $strip>>>.parse(data: unknown, params?: z.core.ParseContext<z.core.$ZodIssue>): {
    id: string;
    role: "admin" | "user";
}
parse
('{"id": "550e8400-e29b-41d4-a716-446655440000", "role": "admin"}');

Veredicto: ¿Cuándo usar cada uno?#

Estrategias de Validación

Cuándo brilla .pipe()

  • Para separar sanitización de validación.
  • Cuando el output de un transform necesita validación estructural compleja.
  • Para mantener esquemas atómicos y reutilizables.

Cuándo evitarlo

  • Si la validación es simple, .refine() es más directo.
  • Puede hacer el flujo de datos más difícil de seguir si se abusa.

Dominar estas herramientas en Astro 6 nos permite mantener un Content Layer limpio, donde el esquema no solo valida que los archivos existan, sino que asegura que la integridad del grafo de conocimiento sea indestructible desde el parseo inicial.

La tipado seguro es paz mental.

Podés profundizar en cómo esto afecta al rendimiento en el Edge en nuestro TIL sobre Zod 4 en Edge Runtimes o revisar la guía de migración a Zod 4.

g CO₂
Enlace copiado al portapapeles