Senior 3 min · April 14, 2026

Zod Advanced Patterns — z.any() Hid a 3-Week Schema Change

TypeError: response.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • 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
Plain-English First

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.

schemas/payment-schema.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import { z } from 'zod';

// Each variant has a z.literal() discriminator
const CardPaymentSchema = z.object({
  type: z.literal('card'),
  last4: z.string().length(4),
  brand: z.enum(['visa', 'mastercard', 'amex']),
  expiryMonth: z.number().int().min(1).max(12),
  expiryYear: z.number().int().min(2024),
});

const BankPaymentSchema = z.object({
  type: z.literal('bank'),
  accountNumber: z.string().regex(/^\*{4}\d{4}$/),
  routingNumber: z.string().length(9),
  bankName: z.string().min(1),
});

const CryptoPaymentSchema = z.object({
  type: z.literal('crypto'),
  walletAddress: z.string().regex(/^0x[a-fA-F0-9]{40}$/),
  network: z.enum(['ethereum', 'polygon', 'arbitrum']),
  token: z.string().min(1),
});

// Discriminated union — validates 'type' first
 export const PaymentSchema = z.discriminatedUnion('type', [
  CardPaymentSchema,
  BankPaymentSchema,
  CryptoPaymentSchema,
]);

export type Payment = z.infer<typeof PaymentSchema>;

export function processPayment(raw: unknown) {
  const result = PaymentSchema.safeParse(raw);
  if (!result.success) throw new Error('Invalid payment');
  const payment = result.data;
  switch (payment.type) {
    case 'card': return chargeCard(payment.last4, payment.brand);
    case 'bank': return debitBank(payment.accountNumber, payment.routingNumber);
    case 'crypto': return transferCrypto(payment.walletAddress, payment.network);
  }
}
Discriminated Union Mental Model
  • 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() not z.string() for discriminator
Production Insight
z.union() tries every branch — confusing errors. z.discriminatedUnion() reads discriminator first — precise errors, faster. Rule: if union has shared field with unique values, use z.discriminatedUnion().
Key Takeaway
Discriminated unions validate discriminator first, then only matching branch — faster and more precise than z.union(). Punchline: if your union has a shared field, use z.discriminatedUnion().
Union Type Decisions
IfResponse has shared discriminator field
UseUse z.discriminatedUnion() — O(1) branch selection
IfNo shared field
UseUse z.union() — tries branches in order
IfSingle shape with optional fields
UseUse z.object() with .optional()
IfArray of mixed types
UseUse z.array(z.discriminatedUnion(...))

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.

schemas/recursive-schema.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import { z } from 'zod';

function getMaxDepth(node: unknown, depth = 0): number {
  if (!node || typeof node !== 'object') return depth;
  const children = (node as any).children;
  if (!Array.isArray(children)) return depth;
  return Math.max(...children.map((c: any) => getMaxDepth(c, depth + 1)), depth);
}

const CommentBase = z.object({
  id: z.string().uuid(),
  author: z.string().min(1).max(100),
  content: z.string().min(1).max(10000),
  createdAt: z.string().datetime(),
});

export interface Comment extends z.infer<typeof CommentBase> {
  children: Comment[];
}

export const CommentSchema: z.ZodType<Comment> = CommentBase.extend({
  children: z.lazy(() => z.array(CommentSchema)).default([]),
}).superRefine((data, ctx) => {
  const depth = getMaxDepth(data);
  if (depth > 10) ctx.addIssue({ code: 'custom', message: `Max depth 10 exceeded (got ${depth})`, path: ['children'] });
});
Always Add a Depth Limit
Without maxDepth, malicious payload with 10,000 levels causes RangeError. Add .superRefine() depth check. Limits: comments 10, files 20, ASTs 50.
Production Insight
z.lazy() breaks circular dependency. Always add maxDepth. Rule: if schema references itself, it can infinite-loop — add guard.
Key Takeaway
z.lazy() enables recursion. Always add maxDepth. Punchline: self-referencing schema = potential DoS without guard.
Recursive Schema Decisions
IfChildren of same type
UseUse z.lazy(() => z.array(Schema)) with maxDepth
IfParent reference
UseUse z.lazy() for parent, don't validate parent.children
IfMutual recursion A↔B
UseTwo z.lazy() schemas, both with depth guards
IfFixed depth (5 levels)
UseDefine explicitly, no lazy needed

Transformations: 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.

schemas/transform-schema.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import { z } from 'zod';

// Zod 4: coerce is preferred over preprocess for primitives
const PaginationSchema = z.object({
  page: z.coerce.number().int().positive().default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
});

// String → Date → validate future date with .pipe()
const FutureDateSchema = z.string().datetime()
  .pipe(z.coerce.date())
  .pipe(z.date().min(new Date()));

const EventSchema = z.object({
  id: z.string().uuid(),
  title: z.string().min(1).max(200),
  startsAt: z.string().datetime().pipe(z.coerce.date()),
  endsAt: z.string().datetime().pipe(z.coerce.date()),
}).transform(event => ({
  ...event,
  durationMinutes: Math.round((event.endsAt.getTime() - event.startsAt.getTime()) / 60000),
  isPast: event.endsAt < new Date(),
}));

// Flatten nested API
const ApiUserSchema = z.object({
  data: z.object({ user: z.object({
    id: z.string().uuid(),
    profile: z.object({ firstName: z.string(), lastName: z.string() })
  })})
}).transform(({data}) => ({
  id: data.user.id,
  fullName: `${data.user.profile.firstName} ${data.user.profile.lastName}`,
}));
Pro Tip: Use .pipe() to Chain
Production Insight
coerce for primitives, preprocess for complex, transform for output, pipe for re-validation. Rule: coerce→validate→transform.
Key Takeaway
preprocess/coerce input, validate, transform output. Punchline: get pipeline order right and your boundary is bulletproof.
Transformation Decisions
IfString number from query params
UseUse z.coerce.number()
IfParse JSON string
UseUse z.preprocess(JSON.parse, schema)
IfConvert to different shape
UseUse .transform() after schema
IfTransform needs validation
UseUse .pipe() to chain schemas

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.

schemas/composition.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { z } from 'zod';

// Zod 4 nominal types
 export const UserId = z.string().uuid().brand<'UserId'>();
export type UserId = z.infer<typeof UserId>;

const BaseUser = z.object({
  id: UserId,
  email: z.string().email(),
  createdAt: z.string().datetime(),
});

export const UserWithProfile = BaseUser.extend({
  firstName: z.string().min(1),
  lastName: z.string().min(1),
  avatar: z.string().url().nullable(),
});

export const UserPublic = UserWithProfile.pick({ id: true, firstName: true, lastName: true, avatar: true });
export const UserUpdate = UserWithProfile.partial(); // PATCH
Composition = CSS Classes
  • .extend() adds fields
  • .merge() combines, second wins
  • .pick()/.omit() for API shapes
  • .partial() for PATCH
  • .brand() prevents ID mixing
Production Insight
Duplicating fields across 20 schemas is maintenance trap. Base + compose eliminates it. Rule: field in 3+ schemas → extract to base.
Key Takeaway
Base schema + compose eliminates duplication. .brand() gives nominal types. Punchline: duplication = trap.
Composition Decisions
IfAdd fields
UseUse .extend()
IfPublic response subset
UseUse .pick()
IfRemove sensitive fields
UseUse .omit()
IfCombine schemas
UseUse .merge()
IfPrevent string ID mixing
UseUse .brand<'UserId'>()

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.

schemas/refinements.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { z } from 'zod';

const Password = z.string().superRefine((pw, ctx) => {
  if (pw.length < 8) ctx.addIssue({code:'custom', message:'Min 8 chars'});
  if (!/[A-Z]/.test(pw)) ctx.addIssue({code:'custom', message:'Need uppercase'});
  if (!/[0-9]/.test(pw)) ctx.addIssue({code:'custom', message:'Need number'});
});

// Async refinement — Zod 4
const UniqueEmail = z.string().email().superRefine(async (email, ctx) => {
  const exists = await db.user.count({ where: { email } });
  if (exists) ctx.addIssue({ code:'custom', message:'Email taken' });
});

// Must use parseAsync for async refinements
export const Register = z.object({
  email: UniqueEmail,
  password: Password,
  confirm: z.string()
}).refine(d => d.password === d.confirm, { error: 'Passwords must match', path: ['confirm'] });
superRefine vs refine
Production Insight
Refinements run after base validation. Use .superRefine() for forms, .refine() for fail-fast APIs. Async requires parseAsync.
Key Takeaway
Encode business rules in schemas, not handlers. superRefine reports all errors. Punchline: rules in schema = single source of truth.
Refinement Decisions
IfSingle check
UseUse .refine()
IfMultiple rules
UseUse .superRefine()
IfDB/API check
UseUse async superRefine + parseAsync()
IfConditional required
UseUse superRefine with path

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.

lib/error-handling.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { z } from 'zod';

// Zod 4 global config
z.config({
  customError: (issue) => {
    if (issue.code === 'invalid_type' && issue.expected === 'string') return 'Required';
    if (issue.code === 'too_small') return `Min ${issue.minimum}`;
    return undefined;
  }
});

const Contact = z.object({
  name: z.string({ error: 'Name is required' }).min(1).max(100),
  email: z.string({ error: 'Valid email required' }).email(),
});

export function parseForm(schema, data) {
  const result = schema.safeParse(data);
  if (!result.success) return { success: false, errors: result.error.flatten().fieldErrors };
  return { success: true, data: result.data };
}
flatten() is form-compatible
Production Insight
Zod 4: define errors inline with { error: '...' }, use z.config for globals, flatten for forms.
Key Takeaway
Zod 4 errors are user-friendly by default. flatten() = one-line form integration.
Error Handling
IfPer-field message
Usez.string({ error: '...' })
IfGlobal format
Usez.config({ customError })
IfForm library
UseUse .flatten().fieldErrors
IfNested errors
UseUse .format()
● Production incidentPOST-MORTEMseverity: high

Untyped API response crashes production — z.any() hid a schema change for 3 weeks

Symptom
TypeError: response.data.items.map is not a function. The error occurred in a React component that rendered a list. The component expected response.data.items to be an array, but the API provider changed it to a paginated wrapper: { data: [...], total: 100, page: 1 }. The z.any() schema accepted both shapes without error — the validation passed, but the runtime code broke.
Assumption
The team assumed that 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.
Root cause
z.any() accepts any value without validation. When the API provider changed the response shape, Zod passed the new shape through without error. The TypeScript type inferred from 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.
Fix
Replaced 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.
Key lesson
  • 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
Production debug guideCommon Zod failures and how to diagnose them in production6 entries
Symptom · 01
ZodError thrown but the error message is unhelpful — lists 47 issues for a simple object
Fix
Use .safeParse() and error.flatten() to get a field-to-message map. Zod 4 improved error paths — use error.issues[0].path for exact location.
Symptom · 02
Schema accepts data that should be rejected — validation passes but runtime code breaks
Fix
Check for 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()).
Symptom · 03
Transformed output has wrong types — TypeScript shows the correct type but runtime value is wrong
Fix
Check that .transform() is returning the expected type. Use z.coerce for simple type coercion (string→number) and .pipe() to validate the transformed output. Transform runs after validation.
Symptom · 04
Recursive schema causes maximum call stack exceeded
Fix
Verify that the recursive schema uses z.lazy() — direct self-reference without lazy causes infinite type expansion. Add a maxDepth refinement to prevent infinite recursion.
Symptom · 05
Discriminated union always matches the wrong branch
Fix
Verify the discriminator field exists in every variant and has a unique z.literal() value. The discriminator must be required — Zod reads it before evaluating branches.
Symptom · 06
Schema compilation is slow — TypeScript language server takes 10+ seconds
Fix
Large schemas with many refinements cause inference overhead. Split into smaller sub-schemas and compose with .merge() or .extend(). Zod 4 is 3x faster but deep .superRefine() chains still cost — flatten them.
★ Zod Debug Cheat SheetFast diagnostics for validation failures, schema errors, and type mismatches in Zod 4
ZodError with unhelpful message
Immediate action
Use .safeParse() and inspect error.issues for field paths and messages
Commands
const result = schema.safeParse(data); if (!result.success) console.log(result.error.flatten());
Use result.error.format() for nested structure matching input shape
Fix now
Add custom error messages via z.string({ error: 'Name required' }) or .refine(..., { error: '...' })
Schema accepts invalid data+
Immediate action
Check for z.any(), .passthrough(), or .catch()
Commands
grep -rn 'z\.any()\|passthrough\|\.catch(' lib/schemas/
Add .strict() to object schemas — Zod 4 defaults to strip
Fix now
Replace z.any() with explicit schemas
Recursive schema stack overflow+
Immediate action
Verify z.lazy() wraps the recursive reference
Commands
grep -rn 'z\.lazy' lib/schemas/
Add depth counter and maxDepth refinement
Fix now
Wrap self-references in z.lazy(() => schema) and add maxDepth: 10 guard
Transform produces wrong output type+
Immediate action
Inspect transform return with console.log
Commands
const parsed = schema.parse(input); console.log(parsed);
Use z.coerce.number() for input coercion, .pipe() for post-transform validation
Fix now
Split: z.coerce.number() for input, .transform() for output, .pipe() to re-validate
Zod Validation Methods Comparison
MethodBehavior on FailureReturnsUse Case
.parse()Throws ZodErrorValidated data (T)API routes — fail fast
.safeParse()Returns { success: false }{ success, data/error }Forms — check success
.parseAsync()Throws (async refinements)Promise<T>DB uniqueness checks
.safeParseAsync()Returns result (async)Promise<{success}>Server Actions with async rules
.spa()Deprecated aliasSame as safeParseAsyncLegacy — use safeParseAsync in Zod 4

Key takeaways

1
Discriminated unions = O(1) branch selection in Zod 4
use for polymorphic APIs
2
z.lazy() enables recursion
always add maxDepth guard (10/20/50)
3
Zod 4 pipeline
z.coerce → validate → .transform() → .pipe() re-validate
4
Base schema + extend/pick/omit + .brand() eliminates duplication and prevents ID mixing
5
Encode business rules in .superRefine()
use parseAsync for DB checks
6
.flatten().fieldErrors = one-line RHF integration

Common mistakes to avoid

6 patterns
×

Using z.any() for external API responses

Symptom
API changes shape, z.any() accepts it, app crashes on .map()
Fix
Define explicit schema. Use discriminatedUnion for variants. Add version check.
×

Using .passthrough() to silence unknown keys

Symptom
API sends extra fields, you accept silently. Later field removed, runtime undefined.
Fix
Zod 4 defaults to .strip(). Use .strict() to reject unknowns. Use .catchall() if intentional.
×

No maxDepth on recursive schemas

Symptom
10,000 nested levels → RangeError stack overflow
Fix
Add superRefine depth check: reject >10 for comments, >20 for files
×

Business logic in handlers not schemas

Symptom
Same rule in 3 handlers, one updated, others not
Fix
Encode in .refine()/.superRefine() — single source of truth
×

Transform before validation

Symptom
transform throws on bad input, user sees wrong error
Fix
Use .pipe(): z.string().datetime().pipe(z.coerce.date().refine(d => d > new Date()))
×

z.union() instead of discriminatedUnion

Symptom
Slow, confusing errors trying each branch
Fix
Use z.discriminatedUnion() when variants share literal field
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
Difference between z.union() and z.discriminatedUnion()?
Q02SENIOR
How does z.lazy() work and what risk?
Q03JUNIOR
Zod 4 error handling for React Hook Form?
Q04SENIOR
refine vs superRefine?
Q05SENIOR
Design schema for paginated success/error API?
Q01 of 05SENIOR

Difference between z.union() and z.discriminatedUnion()?

ANSWER
union tries branches sequentially — slow, confusing errors. discriminatedUnion reads literal discriminator first (O(1) in Zod 4), validates only matching branch. Use when variants share field with unique z.literal values.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
Can Zod validate database results?
02
optional vs nullable vs nullish?
03
Zod with tRPC?
04
Validate env vars?
05
Performance of Zod 4?
🔥

That's TypeScript. Mark it forged?

3 min read · try the examples if you haven't

Previous
Advanced TypeScript: Conditional Types & Template Literal Types
15 / 15 · TypeScript
Next
HTML Basics for JavaScript Developers