Suggestions
← TIL
~2 min read
#typescript#zod-4#architecture#patterns

Zod 4: Complex validations without going crazy

If your Zod schema has more than three levels of nesting with conditionals and you’re not using Discriminated Unions, you’re building a typographic time bomb, not a validator. Complexity in modern validations doesn’t stem from simple data types, but from the cross-business rules we try to force into flat structures. Zod 4 solves this data “Specificity Hell” through two fundamental pillars: Discriminated Unions for handling mutually exclusive states and the .pipe() pattern for orchestrating sequential transformations without cluttering business logic.

The trap of ambiguous unions#

When you validate an object that can have different shapes (for example, a payment that can be ‘cash’ or ‘card’), using z.union forces Zod to test each schema until one passes. This is slow and generates cryptic errors.

Refactor
ts
// ❌ Simple Union: Ambiguous and slow
const paymentSchema = z.union([
z.object({ method: z.literal("card"), number: z.string() }),
z.object({ method: z.literal("cash"), amount: z.number() })
]);
// ✅ Discriminated Union: Fast and precise
const paymentSchema = z.discriminatedUnion("method", [
z.object({ method: z.literal("card"), number: z.string() }),
z.object({ method: z.literal("cash"), amount: z.number() })
]);

With the "method" discriminator, Zod knows exactly which schema to use just by looking at one property, drastically improving performance and TypeScript autocompletion.

The .pipe() pattern for sequential validation#

Sometimes you need to transform data before validating it with a complex schema. In Zod 3, we juggled with .transform() and .refine(). In Zod 4, .pipe() allows you to chain complete schemas as if they were production pipelines.

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

// 1. Input Schema (dirty input)
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. Business Schema (final structure)
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. The Pipeline: Parse string -> JSON -> Validate Object 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"}');

Verdict: When to use each?#

Validation Strategies

When .pipe() shines

  • To separate sanitization from validation.
  • When a transform's output needs complex structural validation.
  • To keep schemas atomic and reusable.

When to avoid it

  • If validation is simple, .refine() is more direct.
  • It can make the data flow harder to follow if overused.

Mastering these tools in Astro 6 allows us to maintain a clean Content Layer, where the schema not only validates that files exist but also ensures that the integrity of the knowledge graph is indestructible from the initial parsing.

Type safety is peace of mind.

You can dive deeper into how this affects Edge performance in our TIL on Zod 4 in Edge Runtimes or review the migration guide to Zod 4.

g CO₂
Link copied to clipboard