Zod Advanced Patterns — z.any() Hid a 3-Week Schema Change
TypeError: response.
- Zod validates data at runtime using schemas that mirror TypeScript types — one source of truth for both compile-time and runtime safety
- Discriminated unions handle polymorphic API responses — the schema branches on a discriminator field (type, kind, status)
- z.lazy() enables recursive schemas for nested trees, comment threads, and AST structures — avoids infinite type expansion
- .transform() converts validated input into a different output shape — parse ISO strings to Date, flatten nested objects, compute derived fields
- z.coerce runs before validation for simple coercion (string→number); z.preprocess handles complex parsing; use .pipe() to chain validation after transforms
- Biggest mistake: using z.any() or .passthrough() — they allow unknown shapes/keys through, disabling the validation that catches API drift
Imagine a customs officer at an airport. Every bag goes through an X-ray (schema validation). The officer checks: is this a suitcase or a backpack (discriminated union)? Does this bag contain another bag inside (recursive schema)? Is the weight in kilograms but the form says pounds (transformation)? Does this item need to be unpacked before inspection (preprocessing)? Zod is that customs officer — it inspects every piece of data entering your application and either lets it through with a stamp of approval or sends it back with a detailed report of what went wrong.
Most Zod usage is z.object({ name: z.string(), age: z.number() }). That covers 20% of real-world validation needs. The remaining 80% — polymorphic API responses, recursive data structures, input coercion, schema composition, and conditional validation — requires patterns that the basic tutorial never covers.
Zod 4 (released 2025) rewrote the core for 3x faster parsing and made z.coerce, z.config, and .pipe() first-class. Zod's power is not in validating flat objects. It is in encoding complex business rules as schemas: 'this field is required only when that field is set to true,' 'this array must contain at least one item of each type,' 'this recursive tree must not exceed 10 levels deep.' These rules live in the schema, not scattered across validation middleware, form handlers, and API route guards.
This article covers the advanced patterns that production codebases need: discriminated unions for polymorphic data, recursive schemas for nested structures, transformations for data normalization, composition for schema reuse, and custom refinements for business logic. Each pattern includes the failure scenario it prevents, the implementation, and the decision tree for when to use it.
Discriminated Unions: Type-Safe Polymorphic Data
APIs frequently return polymorphic data — a response that can be one of several shapes depending on a discriminator field. A payment API returns { type: 'card', last4: '4242' } or { type: 'bank', accountNumber: '****1234' }. Without discriminated unions, you validate each field independently and use type guards at runtime — error-prone and verbose.
Zod's z.discriminatedUnion() validates the discriminator field first, then evaluates only the matching branch. This is both faster (Zod 4 optimizes to O(1) branch lookup) and safer (each branch has its own schema). The TypeScript type narrows automatically — after validation, TypeScript knows which fields exist.
The production pattern: define each variant as a z.object() with a z.literal() discriminator. Compose them into a z.discriminatedUnion(). Use the schema in API route handlers. The discriminator determines the code path — a switch statement gives full type narrowing.
The common mistake: using z.union() instead of z.discriminatedUnion(). z.union() tries each branch in order — confusing errors and slower validation. z.discriminatedUnion() reads the discriminator first and evaluates only the matching branch.
- Each variant has a
z.literal()discriminator — unique value per branch - z.discriminatedUnion() reads discriminator first — one branch evaluated
- TypeScript narrows automatically after validation
- Zod 4: up to 10x faster than
z.union()for large unions - Use
z.literal()notz.string()for discriminator
z.union(). Punchline: if your union has a shared field, use z.discriminatedUnion().z.union() — tries branches in orderz.object() with .optional()Recursive Schemas: Validating Nested Trees with z.lazy()
Data structures like comment threads and file trees are recursive — a node contains children that are also nodes. z.lazy() solves this by wrapping self-reference in a function called only during validation.
The production danger: unbounded recursion. Always add maxDepth refinement. Zod 4 handles lazy schemas more efficiently, but malicious 10,000-level payloads still cause stack overflow without a guard.
z.lazy() for parent, don't validate parent.childrenz.lazy() schemas, both with depth guardsTransformations: Coerce, Validate, Transform with .pipe()
Zod 4 separates three stages: coerce input, validate shape, transform output. z.coerce.number() converts strings before validation. z.preprocess handles complex parsing (JSON). .transform() runs after validation. .pipe() chains schemas — first validates/transforms, second validates result.
Production use: convert ISO strings to Date, flatten nested API responses, rename snake_case to camelCase, compute derived values.
Critical: transform runs after validation. If transform throws, it's a logic bug. Use .pipe(z.coerce.date()) to validate transformed output.
z.coerce.number()Schema Composition: Reuse, Extend, and Brand
Production codebases have hundreds of schemas. Define base schemas once, compose with .extend(), .pick(), .omit(), .merge(). Zod 4 adds z.brand() for nominal types — prevents mixing UserId and PostId (both strings).
Pattern: BaseUserSchema with id, email. Extend for profile, settings. Pick for public API. Omit for safe responses. Partial for PATCH.
- .extend() adds fields
- .merge() combines, second wins
- .pick()/.omit() for API shapes
- .partial() for PATCH
- .brand() prevents ID mixing
Custom Refinements: Business Rules and Async Checks
Built-ins cover types. Business rules need .refine() or .superRefine(). .refine() = single check. .superRefine() = multiple errors at once. Zod 4 supports async refinements — use .parseAsync() for DB uniqueness checks.
Refinements run after base validation. Put field-level messages in base schema, business rules in refinements.
Error Handling: User-Friendly Messages in Zod 4
Zod 4 defaults: z.string({ error: 'Name required' }). Global customization via z.config({ customError }). .flatten() produces { fieldErrors } for React Hook Form. .format() mirrors input shape.
Production pattern: per-field errors in schema, global config for format messages, flatten for forms.
flatten() = one-line form integration.Untyped API response crashes production — z.any() hid a schema change for 3 weeks
z.any() schema accepted both shapes without error — the validation passed, but the runtime code broke.z.any() was sufficient for third-party API responses because 'we cannot control their schema.' They did not realize that z.any() disables all validation — it accepts literally anything, including undefined, null, and completely wrong shapes. The schema was a no-op that gave false confidence.z.any() is any — no compile-time safety either. The team had zero protection against schema changes: no runtime validation, no compile-time checks, no error at the boundary.z.any() with a strict schema that described the expected response shape. Added z.discriminatedUnion() for the paginated vs non-paginated response variants. Added a schema version check in the API client that compares the response's _schemaVersion field against the expected version and logs a warning when they differ. Added integration tests that validate the schema against real API responses weekly.- Never use
z.any()for external API responses — it disables all validation and gives false confidence - Schema every external data source at the boundary — the schema is your contract with the outside world
- Use z.discriminatedUnion() for responses that can have multiple shapes — validate each variant explicitly
- Add schema version checking for third-party APIs — detect shape changes before they crash production
error.flatten() to get a field-to-message map. Zod 4 improved error paths — use error.issues[0].path for exact location.z.any(), .passthrough(), or .catch() in the schema — all three disable strict validation. Replace with explicit schemas and use .strict() to reject unknown keys (Zod 4 defaults to .strip()).z.lazy() — direct self-reference without lazy causes infinite type expansion. Add a maxDepth refinement to prevent infinite recursion.z.literal() value. The discriminator must be required — Zod reads it before evaluating branches.Key takeaways
Common mistakes to avoid
6 patternsUsing z.any() for external API responses
z.any() accepts it, app crashes on .map()Using .passthrough() to silence unknown keys
No maxDepth on recursive schemas
Business logic in handlers not schemas
Transform before validation
z.string().datetime().pipe(z.coerce.date().refine(d => d > new Date()))z.union() instead of discriminatedUnion
Interview Questions on This Topic
Difference between z.union() and z.discriminatedUnion()?
Frequently Asked Questions
That's TypeScript. Mark it forged?
3 min read · try the examples if you haven't