Skip to content
Homeβ€Ί JavaScriptβ€Ί Zod Advanced Patterns 2026: Discriminated Unions, Recursion, and Production Validation

Zod Advanced Patterns 2026: Discriminated Unions, Recursion, and Production Validation

Where developers are forged. Β· Structured learning Β· Free forever.
πŸ“ Part of: TypeScript β†’ Topic 15 of 15
Master Zod 4 in 2026: discriminated unions, recursive schemas with z.
πŸ”₯ Advanced β€” solid JavaScript foundation required
In this tutorial, you'll learn
Master Zod 4 in 2026: discriminated unions, recursive schemas with z.
  • Discriminated unions = O(1) branch selection in Zod 4 β€” use for polymorphic APIs
  • z.lazy() enables recursion β€” always add maxDepth guard (10/20/50)
  • Zod 4 pipeline: z.coerce β†’ validate β†’ .transform() β†’ .pipe() re-validate
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
⚑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
🚨 START HERE
Zod Debug Cheat Sheet
Fast diagnostics for validation failures, schema errors, and type mismatches in Zod 4
🟑ZodError with unhelpful message
Immediate ActionUse .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 NowAdd custom error messages via z.string({ error: 'Name required' }) or .refine(..., { error: '...' })
🟑Schema accepts invalid data
Immediate ActionCheck 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 NowReplace z.any() with explicit schemas
🟑Recursive schema stack overflow
Immediate ActionVerify z.lazy() wraps the recursive reference
Commands
grep -rn 'z\.lazy' lib/schemas/
Add depth counter and maxDepth refinement
Fix NowWrap self-references in z.lazy(() => schema) and add maxDepth: 10 guard
🟑Transform produces wrong output type
Immediate ActionInspect 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 NowSplit: z.coerce.number() for input, .transform() for output, .pipe() to re-validate
Production IncidentUntyped API response crashes production β€” z.any() hid a schema change for 3 weeksA team used z.any() for a third-party API response because the schema was complex. The API provider changed the response shape β€” a nested field moved from an object to an array. The application crashed on every request for 12 hours before the team noticed. The crash was in a .map() call on what was previously an object.
SymptomTypeError: 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.
AssumptionThe 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 causez.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.
FixReplaced 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 confidenceSchema every external data source at the boundary β€” the schema is your contract with the outside worldUse z.discriminatedUnion() for responses that can have multiple shapes β€” validate each variant explicitlyAdd 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 production
ZodError thrown but the error message is unhelpful — lists 47 issues for a simple object→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.
Schema accepts data that should be rejected — validation passes but runtime code breaks→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()).
Transformed output has wrong types — TypeScript shows the correct type but runtime value is wrong→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.
Recursive schema causes maximum call stack exceeded→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.
Discriminated union always matches the wrong branch→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.
Schema compilation is slow — TypeScript language server takes 10+ seconds→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.

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.ts Β· TYPESCRIPT
1234567891011121314151617181920212223242526272829303132333435363738394041424344
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);
  }
}
Mental Model
Discriminated Union Mental Model
Think of a sorting machine at a post office. The machine reads the zip code (discriminator) first, then routes the package to the correct bin. It does not inspect every bin.
  • 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.ts Β· TYPESCRIPT
1234567891011121314151617181920212223242526
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.ts Β· TYPESCRIPT
12345678910111213141516171819202122232425262728293031323334
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.ts Β· TYPESCRIPT
1234567891011121314151617181920
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
Mental Model
Composition = CSS Classes
Base schema = .btn. Extend = .btn-primary. Pick = specific properties. Brand = type safety for IDs.
  • .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.ts Β· TYPESCRIPT
1234567891011121314151617181920
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.ts Β· TYPESCRIPT
123456789101112131415161718192021
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()
πŸ—‚ Zod Validation Methods Comparison
Zod 4 parsing methods
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

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

⚠ Common Mistakes to Avoid

    βœ•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 Questions on This Topic

  • QDifference between z.union() and z.discriminatedUnion()?Mid-levelReveal
    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.
  • QHow does z.lazy() work and what risk?Mid-levelReveal
    lazy wraps reference in function, called at validation time, breaks circular dependency. Risk: unbounded recursion β†’ stack overflow. Always add maxDepth superRefine (10-50 levels).
  • QZod 4 error handling for React Hook Form?JuniorReveal
    Use safeParse, then error.flatten().fieldErrors returns { field: string[] }. RHF expects this shape. Define per-field with z.string({ error: '...' }).
  • Qrefine vs superRefine?Mid-levelReveal
    refine = single predicate, stops at first fail. superRefine = multiple ctx.addIssue calls, reports all errors. Use superRefine for forms, async refinements for DB checks (requires parseAsync).
  • QDesign schema for paginated success/error API?SeniorReveal
    z.discriminatedUnion('status', [successSchema, errorSchema]). Success: status:'success', data: z.array(Item), total:number. Error: status:'error', code:string. Use safeParse and switch on status for narrowing.

Frequently Asked Questions

Can Zod validate database results?

Yes β€” parse every DB result at data access layer. Catches schema drift immediately instead of deep runtime errors.

optional vs nullable vs nullish?

.optional() = undefined allowed. .nullable() = null allowed. .nullish() = both. Zod 4: use .optional() for missing API fields, .nullable() for explicit nulls.

Zod with tRPC?

Native in 2026. Define input/output schemas, tRPC uses for runtime validation, type inference, and OpenAPI generation.

Validate env vars?

const Env = z.object({ DATABASE_URL: z.string().url(), PORT: z.coerce.number().default(3000) }); Env.parse(process.env) at startup β€” fail fast.

Performance of Zod 4?

Zod 4 core rewritten: simple schemas <0.1ms, complex with unions/transforms 0.2-0.8ms (3x faster than v3). For >10k validations/sec, cache or use zod-mini.

πŸ”₯
Naren Founder & Author

Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.

← PreviousAdvanced TypeScript: Conditional Types & Template Literal Types
Forged with πŸ”₯ at TheCodeForge.io β€” Where Developers Are Forged