
Zod
- 5k installs
- 186 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
zod is an agent skill: Zod schema validation best practices for type safety, parsing, and error handling. This skill should be used when defining z.object schemas,
About
The zod skill Zod schema validation best practices for type safety, parsing, and error handling. This skill should be used when defining z.object schemas, using z.string validations, safeParse, or z.infer. This skill does NOT cover React Hook Form integration patterns (use react-hook-form skill) or OpenAPI client generation (use orval skill).. Zod Best Practices Comprehensive schema validation guide for Zod in TypeScript applications. Contains 43 rules across 8 categories, prioritized by impact to guide automated refactoring and code generation. When to Apply Reference these guidelines when: - Writing new Zod schemas - Choosing between parse() and safeParse() - Implementing type inference with z.infer - Handling validation errors for user feedback - Composing complex object schemas - Using refinements and transforms - Optimizing bundle size and validation performance - Reviewing Zod code for best practices Rule Categories by Priority | Priority |
- Covers zod quick start, workflow steps, and reference pointers from SKILL.md.
- Tagged for stage build and subphase backend in the closed Skillselion taxonomy.
- Documents prerequisites, permissions filesystem, and compatible agents.
- Includes AEO tagMeta with task queries, keywords, and evidence quotes for discovery.
- Cross-links related skills and generated REFERENCE.md tables where the repo provides them.
Zod by the numbers
- 5,031 all-time installs (skills.sh)
- +505 installs in the week ending Jul 29, 2026 (Skillselion tracking)
- Ranked #138 of 4,353 Backend & APIs skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Jul 31, 2026 (Skillselion catalog sync)
zod capabilities & compatibility
- Capabilities
- zod documented workflow · quick start examples · reference parameter lookup · taxonomy aligned metadata · aeo discovery fields
- Use cases
- api development · testing
What zod says it does
Zod schema validation best practices for type safety, parsing, and error handling. This skill should
npx skills add https://github.com/pproenca/dot-skills --skill zodAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 5k |
|---|---|
| repo stars | ★ 186 |
| Security audit | 3 / 3 scanners passed |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do I run zod correctly without guessing steps, tools, or parameters?
Zod schema validation best practices for type safety, parsing, and error handling. This skill should be used when defining z.object schemas, using z.string validations, safeParse, or z.infer. Thi
Who is it for?
Teams using zod when SKILL.md triggers match the user request.
Skip if: Skip when the task is outside zod documented triggers or sibling skill scope.
When should I use this skill?
User mentions zod, related trigger phrases, or asks to follow this SKILL.md workflow.
What you get
Completed zod workflow with outputs and checks defined in SKILL.md.
- zod output per SKILL.md
By the numbers
- Stage build/backend
- Category Backend & APIs
- Complexity intermediate
Files
Zod Best Practices
Comprehensive schema validation guide for Zod in TypeScript applications. Contains 43 rules across 8 categories, prioritized by impact to guide automated refactoring and code generation.
When to Apply
Reference these guidelines when:
- Writing new Zod schemas
- Choosing between parse() and safeParse()
- Implementing type inference with z.infer
- Handling validation errors for user feedback
- Composing complex object schemas
- Using refinements and transforms
- Optimizing bundle size and validation performance
- Reviewing Zod code for best practices
Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|---|---|---|---|
| 1 | Schema Definition | CRITICAL | schema- |
| 2 | Parsing & Validation | CRITICAL | parse- |
| 3 | Type Inference | HIGH | type- |
| 4 | Error Handling | HIGH | error- |
| 5 | Object Schemas | MEDIUM-HIGH | object- |
| 6 | Schema Composition | MEDIUM | compose- |
| 7 | Refinements & Transforms | MEDIUM | refine- |
| 8 | Performance & Bundle | LOW-MEDIUM | perf- |
Quick Reference
1. Schema Definition (CRITICAL)
schema-use-primitives-correctly- Use correct primitive schemas for each typeschema-use-unknown-not-any- Use z.unknown() instead of z.any() for type safetyschema-avoid-optional-abuse- Avoid overusing optional fieldsschema-string-validations- Apply string validations at schema definitionschema-use-enums- Use enums for fixed string valuesschema-coercion-for-form-data- Use coercion for form and query data
2. Parsing & Validation (CRITICAL)
parse-use-safeparse- Use safeParse() for user inputparse-async-for-async-refinements- Use parseAsync for async refinementsparse-handle-all-issues- Handle all validation issues not just firstparse-validate-early- Validate at system boundariesparse-avoid-double-validation- Avoid validating same data twiceparse-never-trust-json- Never trust JSON.parse output
3. Type Inference (HIGH)
type-use-z-infer- Use z.infer instead of manual typestype-input-vs-output- Distinguish z.input from z.infer for transformstype-export-schemas-and-types- Export both schemas and inferred typestype-branded-types- Use branded types for domain safetytype-enable-strict-mode- Enable TypeScript strict mode
4. Error Handling (HIGH)
error-custom-messages- Provide custom error messageserror-use-flatten- Use flatten() for form error displayerror-path-for-nested- Use issue.path for nested error locationerror-i18n- Implement internationalized error messageserror-avoid-throwing-in-refine- Return false instead of throwing in refine
5. Object Schemas (MEDIUM-HIGH)
object-strict-vs-strip- Choose strict() vs strip() for unknown keysobject-partial-for-updates- Use partial() for update schemasobject-pick-omit- Use pick() and omit() for schema variantsobject-extend-for-composition- Use extend() for adding fieldsobject-optional-vs-nullable- Distinguish optional() from nullable()object-discriminated-unions- Use discriminated unions for type narrowing
6. Schema Composition (MEDIUM)
compose-shared-schemas- Extract shared schemas into reusable modulescompose-intersection- Use intersection() for type combinationscompose-lazy-recursive- Use z.lazy() for recursive schemascompose-preprocess- Use preprocess() for data normalizationcompose-pipe- Use pipe() for multi-stage validation
7. Refinements & Transforms (MEDIUM)
refine-vs-superrefine- Choose refine() vs superRefine() correctlyrefine-transform-coerce- Distinguish transform() from refine() and coerce()refine-add-path- Add path to refinement errorsrefine-defaults- Use default() for optional fields with defaultsrefine-catch- Use catch() for fault-tolerant parsing
8. Performance & Bundle (LOW-MEDIUM)
perf-cache-schemas- Cache schema instancesperf-zod-mini- Use Zod Mini for bundle-sensitive applicationsperf-avoid-dynamic-creation- Avoid dynamic schema creation in hot pathsperf-lazy-loading- Lazy load large schemasperf-arrays- Optimize large array validation
How to Use
Read individual reference files for detailed explanations and code examples:
- Section definitions - Category structure and impact levels
- Rule template - Template for adding new rules
- Individual rules:
references/{prefix}-{slug}.md
Full Compiled Document
For the complete guide with all rules expanded: AGENTS.md
Related Skills
- For React Hook Form integration, see
react-hook-formskill - For API client generation, see
orvalskill
Sources
Zod
Version 1.0.0 community January 2026
Note:
This document is mainly for agents and LLMs to follow when maintaining,
generating, or refactoring codebases. Humans may also find it useful,
but guidance here is optimized for automation and consistency by AI-assisted workflows.
---
Abstract
Comprehensive schema validation guide for Zod in TypeScript applications, designed for AI agents and LLMs. Contains 43 rules across 8 categories, prioritized by impact from critical (schema definition, parsing) to incremental (performance, bundle optimization). Each rule includes detailed explanations, real-world examples comparing incorrect vs. correct implementations, and specific impact metrics to guide automated refactoring and code generation.
---
Table of Contents
1. Schema Definition — CRITICAL
- 1.1 Apply String Validations at Schema Definition — CRITICAL (Unvalidated strings allow SQL injection, XSS, and malformed data; validating at schema level catches issues at the boundary)
- 1.2 Avoid Overusing Optional Fields — CRITICAL (Excessive optional fields create schemas that accept almost anything; forces null checks throughout codebase)
- 1.3 Use Coercion for Form and Query Data — CRITICAL (Form data and query params are always strings; without coercion, z.number() rejects "42" and z.boolean() rejects "true")
- 1.4 Use Enums for Fixed String Values — CRITICAL (Plain strings accept any value including typos; enums restrict to valid values and enable autocomplete)
- 1.5 Use Primitive Schemas Correctly — CRITICAL (Incorrect primitive selection causes validation to pass on wrong types; using z.any() or z.unknown() loses all type safety)
- 1.6 Use z.unknown() Instead of z.any() — CRITICAL (z.any() bypasses TypeScript's type system entirely; z.unknown() forces type narrowing before use)
2. Parsing & Validation — CRITICAL
- 2.1 Avoid Double Validation — HIGH (Parsing the same data twice wastes CPU cycles; in hot paths this adds measurable latency)
- 2.2 Handle All Validation Issues Not Just First — CRITICAL (Showing only the first error forces users to fix-submit-fix repeatedly; collecting all errors improves UX dramatically)
- 2.3 Never Trust JSON.parse Output — CRITICAL (JSON.parse returns any type; unvalidated JSON allows type confusion attacks and runtime crashes)
- 2.4 Use parseAsync for Async Refinements — CRITICAL (Using parse() with async refinements throws an error; async validation silently fails or crashes the application)
- 2.5 Use safeParse() for User Input — CRITICAL (parse() throws exceptions on invalid data; unhandled exceptions crash servers and expose stack traces to users)
- 2.6 Validate at System Boundaries — CRITICAL (Validating deep in business logic allows corrupt data to propagate; validating at boundaries catches issues before they spread)
3. Type Inference — HIGH
- 3.1 Distinguish z.input from z.infer for Transforms — HIGH (Using wrong type with transforms causes TypeScript errors; z.input captures pre-transform shape, z.infer captures post-transform)
- 3.2 Enable TypeScript Strict Mode — HIGH (Without strict mode, Zod's type inference is unreliable; undefined and null slip through, defeating the purpose of validation)
- 3.3 Export Both Schemas and Inferred Types — HIGH (Exporting only schemas forces consumers to derive types themselves; exporting both reduces boilerplate and improves DX)
- 3.4 Use Branded Types for Domain Safety — HIGH (Plain string IDs are interchangeable, allowing userId where orderId is expected; branded types catch these bugs at compile time)
- 3.5 Use z.infer Instead of Manual Types — HIGH (Manual type definitions drift from schemas over time; z.infer guarantees types match validation exactly)
4. Error Handling — HIGH
- 4.1 Implement Internationalized Error Messages — HIGH (Hardcoded English messages exclude non-English users; error maps enable localized messages for global applications)
- 4.2 Provide Custom Error Messages — HIGH (Default messages like "Expected string, received number" confuse users; custom messages like "Email is required" are actionable)
- 4.3 Return False Instead of Throwing in Refine — HIGH (Throwing in refine stops validation early, hiding other errors; returning false allows Zod to collect all issues)
- 4.4 Use flatten() for Form Error Display — HIGH (Raw ZodError.issues requires manual path parsing; flatten() provides field-keyed errors ready for form display)
- 4.5 Use issue.path for Nested Error Location — HIGH (Without path information, users can't identify which nested field failed; path provides exact location in complex objects)
5. Object Schemas — MEDIUM-HIGH
- 5.1 Choose strict() vs strip() for Unknown Keys — MEDIUM-HIGH (Default passthrough mode leaks unexpected data; strict() catches schema mismatches, strip() silently removes extras)
- 5.2 Distinguish optional() from nullable() — MEDIUM-HIGH (Confusing undefined and null semantics causes "property does not exist" vs "property is null" bugs; choose deliberately)
- 5.3 Use Discriminated Unions for Type Narrowing — MEDIUM-HIGH (Regular unions require manual type guards; discriminated unions enable TypeScript's automatic narrowing and Zod's optimized parsing)
- 5.4 Use extend() for Adding Fields — MEDIUM-HIGH (Merging objects manually loses type information; extend() preserves types and allows overriding fields safely)
- 5.5 Use partial() for Update Schemas — MEDIUM-HIGH (Creating separate update schemas duplicates definitions; partial() derives update schema from base, staying in sync)
- 5.6 Use pick() and omit() for Schema Variants — MEDIUM-HIGH (Copying fields between schemas creates duplication; pick/omit derive variants that stay in sync with base schema)
6. Schema Composition — MEDIUM
- 6.1 Extract Shared Schemas into Reusable Modules — MEDIUM (Duplicating schemas across files leads to inconsistency; shared schemas ensure single source of truth)
- 6.2 Use intersection() for Type Combinations — MEDIUM (Manual field combination loses type relationships; intersection creates proper TypeScript intersection types)
- 6.3 Use pipe() for Multi-Stage Validation — MEDIUM (Chaining transforms loses intermediate type info; pipe() explicitly shows data flow through validation stages)
- 6.4 Use preprocess() for Data Normalization — MEDIUM (Validating before cleaning data causes false rejections; preprocess() normalizes input before schema validation runs)
- 6.5 Use z.lazy() for Recursive Schemas — MEDIUM (Recursive types reference themselves before definition; z.lazy() defers evaluation to enable self-referential schemas)
7. Refinements & Transforms — MEDIUM
- 7.1 Add Path to Refinement Errors — MEDIUM (Errors without path show at object level; adding path highlights the specific field that failed)
- 7.2 Choose refine() vs superRefine() Correctly — MEDIUM (refine() only reports one error; superRefine() enables multiple issues and custom error codes)
- 7.3 Distinguish transform() from refine() and coerce() — MEDIUM (Using wrong method causes validation to pass with wrong data; each method has distinct purpose)
- 7.4 Use catch() for Fault-Tolerant Parsing — MEDIUM (parse() fails on first invalid field; catch() provides fallback values, enabling partial success with degraded data)
- 7.5 Use default() for Optional Fields with Defaults — MEDIUM (Manual default handling spreads logic across codebase; .default() centralizes defaults in schema)
8. Performance & Bundle — LOW-MEDIUM
- 8.1 Avoid Dynamic Schema Creation in Hot Paths — LOW-MEDIUM (Zod 4's JIT compilation makes schema creation slower; creating schemas in loops adds ~0.15ms per creation)
- 8.2 Cache Schema Instances — LOW-MEDIUM (Creating schemas on every render/call wastes CPU; module-level or memoized schemas are created once)
- 8.3 Lazy Load Large Schemas — LOW-MEDIUM (Large schemas increase initial bundle and parse time; dynamic imports defer loading until needed)
- 8.4 Optimize Large Array Validation — LOW-MEDIUM (Validating 10,000 items takes ~100ms; early exits, sampling, or batching reduce time for large datasets)
- 8.5 Use Zod Mini for Bundle-Sensitive Applications — LOW-MEDIUM (Full Zod is ~17kb gzipped; Zod Mini is ~1.9kb - 85% smaller for frontend-critical bundles)
---
References
1. https://zod.dev/ 2. https://zod.dev/v4 3. https://github.com/colinhacks/zod 4. https://zod.dev/packages/mini 5. https://www.totaltypescript.com/tutorials/zod 6. https://zod.dev/error-handling 7. https://zod.dev/api
---
Source Files
This document was compiled from individual reference files. For detailed editing or extension:
| File | Description |
|---|---|
| references/_sections.md | Category definitions and impact ordering |
| assets/templates/_template.md | Template for creating new rules |
| SKILL.md | Quick reference entry point |
| metadata.json | Version and reference URLs |
Rule Title Here
1-3 sentences explaining WHY this matters. Focus on validation implications and cascade effects.
Incorrect (what's wrong):
// Bad code example - production-realistic, not strawman
// Comments explaining the costCorrect (what's right):
// Good code example - minimal diff from incorrect
// Comments explaining the benefitWhen NOT to use this pattern:
- Exception 1
- Exception 2
Reference: Reference Title
{
"version": "1.1.5",
"organization": "community",
"technology": "Zod",
"date": "January 2026",
"abstract": "Comprehensive schema validation guide for Zod in TypeScript applications, designed for AI agents and LLMs. Contains 43 rules across 8 categories, prioritized by impact from critical (schema definition, parsing) to incremental (performance, bundle optimization). Each rule includes detailed explanations, real-world examples comparing incorrect vs. correct implementations, and specific impact metrics to guide automated refactoring and code generation.",
"references": [
"https://zod.dev/",
"https://zod.dev/v4",
"https://github.com/colinhacks/zod",
"https://zod.dev/packages/mini",
"https://www.totaltypescript.com/tutorials/zod",
"https://zod.dev/error-handling",
"https://zod.dev/api"
],
"categories": [
{
"name": "Schema Definition",
"prefix": "schema",
"impact": "CRITICAL",
"ruleCount": 6
},
{
"name": "Parsing & Validation",
"prefix": "parse",
"impact": "CRITICAL",
"ruleCount": 6
},
{
"name": "Type Inference",
"prefix": "type",
"impact": "HIGH",
"ruleCount": 5
},
{
"name": "Error Handling",
"prefix": "error",
"impact": "HIGH",
"ruleCount": 5
},
{
"name": "Object Schemas",
"prefix": "object",
"impact": "MEDIUM-HIGH",
"ruleCount": 6
},
{
"name": "Schema Composition",
"prefix": "compose",
"impact": "MEDIUM",
"ruleCount": 5
},
{
"name": "Refinements & Transforms",
"prefix": "refine",
"impact": "MEDIUM",
"ruleCount": 5
},
{
"name": "Performance & Bundle",
"prefix": "perf",
"impact": "LOW-MEDIUM",
"ruleCount": 5
}
],
"totalRules": 43,
"zodVersion": "4.x",
"category": "API"
}
Zod Best Practices Skill
A comprehensive guide for using Zod effectively in TypeScript applications. This skill provides 42 rules across 8 categories, organized by impact to help AI agents and developers write better validation code.
Overview
Zod is a TypeScript-first schema declaration and validation library. This skill covers best practices for:
- Schema Definition: Choosing correct types, avoiding
z.any(), proper string validations - Parsing & Validation: Using
safeParse(), async validation, error handling - Type Inference: Leveraging
z.infer, distinguishing input/output types - Error Handling: Custom messages, internationalization, form error display
- Object Schemas: strict/strip modes, partial updates, discriminated unions
- Schema Composition: Reusable schemas, intersections, recursive types
- Refinements & Transforms: Custom validation, data transformation
- Performance: Caching, Zod Mini, lazy loading, batch validation
Usage
For Claude Code / AI Agents
The skill is automatically loaded when working with Zod code. Reference specific rules:
See rules/parse-use-safeparse.md for safeParse best practicesFor Developers
Read SKILL.md for a quick reference, or AGENTS.md for the full compiled guide.
File Structure
zod/
├── SKILL.md # Quick reference with rule index
├── AGENTS.md # Full compiled guide (all rules)
├── metadata.json # Version, categories, references
├── README.md # This file
└── rules/
├── _sections.md # Category definitions
├── _template.md # Rule template
├── schema-*.md # Schema definition rules
├── parse-*.md # Parsing rules
├── type-*.md # Type inference rules
├── error-*.md # Error handling rules
├── object-*.md # Object schema rules
├── compose-*.md # Composition rules
├── refine-*.md # Refinement rules
└── perf-*.md # Performance rulesRule Categories
| Priority | Category | Rules | Impact |
|---|---|---|---|
| 1 | Schema Definition | 6 | CRITICAL |
| 2 | Parsing & Validation | 6 | CRITICAL |
| 3 | Type Inference | 5 | HIGH |
| 4 | Error Handling | 5 | HIGH |
| 5 | Object Schemas | 6 | MEDIUM-HIGH |
| 6 | Schema Composition | 5 | MEDIUM |
| 7 | Refinements & Transforms | 5 | MEDIUM |
| 8 | Performance & Bundle | 5 | LOW-MEDIUM |
Key Principles
1. Type Safety First: Always use z.infer, never duplicate types manually 2. Validate at Boundaries: Parse external data immediately at entry points 3. User-Friendly Errors: Provide custom messages, collect all issues 4. Single Source of Truth: Schema defines validation AND TypeScript types 5. Composition Over Duplication: Use extend, pick, omit, partial
References
Sections
This file defines all sections, their ordering, impact levels, and descriptions. The section ID (in parentheses) is the filename prefix used to group rules.
---
1. Schema Definition (schema)
Impact: CRITICAL Description: Schema definition is the foundation of all Zod validation; incorrect or overly permissive schemas cascade errors through your entire application, allowing invalid data to corrupt downstream logic.
2. Parsing & Validation (parse)
Impact: CRITICAL Description: Parsing is the core Zod operation; using parse() vs safeParse() incorrectly causes either unhandled exceptions crashing your app or silent failures that let invalid data through.
3. Type Inference (type)
Impact: HIGH Description: Zod's TypeScript integration eliminates duplicate type definitions; poor inference practices force manual type declarations that drift from schemas, losing the core benefit of Zod.
4. Error Handling (error)
Impact: HIGH Description: Error handling determines user experience; poorly structured error handling produces cryptic messages that harm UX and make debugging validation failures nearly impossible.
5. Object Schemas (object)
Impact: MEDIUM-HIGH Description: Objects are the most common schema type; misconfiguring strict/passthrough/strip modes either leaks unexpected data to clients or fails validation on legitimate requests.
6. Schema Composition (compose)
Impact: MEDIUM Description: Schema composition enables reuse and maintainability; poor composition patterns lead to duplicated schemas that drift apart or deeply nested structures that are impossible to maintain.
7. Refinements & Transforms (refine)
Impact: MEDIUM Description: Refinements and transforms handle custom validation and data coercion; choosing the wrong method causes performance issues, incorrect error aggregation, or async parsing failures.
8. Performance & Bundle (perf)
Impact: LOW-MEDIUM Description: Zod's performance and bundle size affect application startup and validation throughput; understanding when to use Zod Mini or cache schemas prevents unnecessary overhead in performance-critical paths.
Use intersection() for Type Combinations
When you need an object that satisfies multiple schemas simultaneously (like combining a base type with mixins), use .and() or z.intersection(). This creates proper TypeScript intersection types and validates against all schemas.
Incorrect (manual combination):
import { z } from 'zod'
const timestampsSchema = z.object({
createdAt: z.date(),
updatedAt: z.date(),
})
const softDeleteSchema = z.object({
deletedAt: z.date().nullable(),
deletedBy: z.string().nullable(),
})
const userSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
})
// Manual combination - verbose and error-prone
const fullUserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
createdAt: z.date(),
updatedAt: z.date(),
deletedAt: z.date().nullable(),
deletedBy: z.string().nullable(),
})Correct (using intersection):
import { z } from 'zod'
const timestampsSchema = z.object({
createdAt: z.date(),
updatedAt: z.date(),
})
const softDeleteSchema = z.object({
deletedAt: z.date().nullable(),
deletedBy: z.string().nullable(),
})
const userSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
})
// Using .and() for intersection
const fullUserSchema = userSchema
.and(timestampsSchema)
.and(softDeleteSchema)
// Or using z.intersection()
const fullUserSchema2 = z.intersection(
z.intersection(userSchema, timestampsSchema),
softDeleteSchema
)
type FullUser = z.infer<typeof fullUserSchema>
// {
// id: string;
// name: string;
// email: string;
// createdAt: Date;
// updatedAt: Date;
// deletedAt: Date | null;
// deletedBy: string | null;
// }Creating mixins:
// Reusable mixins
const auditable = z.object({
createdBy: z.string(),
updatedBy: z.string(),
})
const versioned = z.object({
version: z.number().int().positive(),
})
const tagged = z.object({
tags: z.array(z.string()),
})
// Apply mixins to any schema
function withAudit<T extends z.ZodRawShape>(schema: z.ZodObject<T>) {
return schema.and(auditable).and(timestampsSchema)
}
function withVersioning<T extends z.ZodRawShape>(schema: z.ZodObject<T>) {
return schema.and(versioned)
}
// Usage
const documentSchema = z.object({
id: z.string(),
title: z.string(),
content: z.string(),
})
const fullDocumentSchema = withAudit(withVersioning(documentSchema))Intersection vs Merge:
// .merge() - replaces fields from first with second
const a = z.object({ x: z.string(), y: z.number() })
const b = z.object({ y: z.string() }) // y is string, not number
a.merge(b) // { x: string, y: string } - b's y wins
// .and() - requires fields to be compatible
// If both have y with different types, intersection fails at runtime
a.and(b) // Validation will fail - y can't be both number and stringWhen NOT to use this pattern:
- When schemas have overlapping fields with different types (use merge)
- When you need to override fields (use extend)
- Simple cases where extend works fine
Reference: Zod API - intersection
Use z.lazy() for Recursive Schemas
TypeScript can't infer recursive Zod schema types automatically. Use z.lazy() to defer schema evaluation and manually provide the type annotation. This enables tree structures, nested comments, and other self-referential data.
Incorrect (direct self-reference):
import { z } from 'zod'
// This fails - categorySchema used before it's defined
const categorySchema = z.object({
id: z.string(),
name: z.string(),
children: z.array(categorySchema), // Error: Block-scoped variable used before declaration
})Correct (using z.lazy with type annotation):
import { z } from 'zod'
// Define the type manually
interface Category {
id: string
name: string
children: Category[]
}
// Use z.lazy() to defer schema reference
const categorySchema: z.ZodType<Category> = z.object({
id: z.string(),
name: z.string(),
children: z.lazy(() => z.array(categorySchema)),
})
// Now it works
const tree = categorySchema.parse({
id: '1',
name: 'Electronics',
children: [
{
id: '2',
name: 'Phones',
children: [
{ id: '3', name: 'iPhones', children: [] },
{ id: '4', name: 'Android', children: [] },
],
},
],
})Common recursive patterns:
// Comments with replies
interface Comment {
id: string
content: string
author: string
replies: Comment[]
}
const commentSchema: z.ZodType<Comment> = z.object({
id: z.string(),
content: z.string(),
author: z.string(),
replies: z.lazy(() => z.array(commentSchema)),
})
// Binary tree
interface TreeNode {
value: number
left: TreeNode | null
right: TreeNode | null
}
const treeNodeSchema: z.ZodType<TreeNode> = z.object({
value: z.number(),
left: z.lazy(() => treeNodeSchema.nullable()),
right: z.lazy(() => treeNodeSchema.nullable()),
})
// Nested menu structure
interface MenuItem {
label: string
href?: string
children?: MenuItem[]
}
const menuItemSchema: z.ZodType<MenuItem> = z.object({
label: z.string(),
href: z.string().url().optional(),
children: z.lazy(() => z.array(menuItemSchema)).optional(),
})JSON Schema (any valid JSON):
type JSONValue =
| string
| number
| boolean
| null
| JSONValue[]
| { [key: string]: JSONValue }
const jsonValueSchema: z.ZodType<JSONValue> = z.lazy(() =>
z.union([
z.string(),
z.number(),
z.boolean(),
z.null(),
z.array(jsonValueSchema),
z.record(jsonValueSchema),
])
)Performance consideration:
// z.lazy() has minimal overhead - the function is called once
// and the schema is cached. Safe to use in hot paths.
// If validating many recursive structures, the schema itself
// is only built once. Validation performance depends on data depth.When NOT to use this pattern:
- Non-recursive schemas (lazy adds unnecessary indirection)
- When you can flatten the structure instead
Reference: Zod API - Recursive Types
Use pipe() for Multi-Stage Validation
When data needs to pass through multiple validation stages (coerce string to number, then validate range, then transform to currency), use .pipe() to chain schemas. This makes the data transformation pipeline explicit and each stage's type clear.
Incorrect (unclear transformation chain):
import { z } from 'zod'
// All transforms in one long chain - hard to understand stages
const priceSchema = z
.string()
.transform((s) => parseFloat(s.replace(/[$,]/g, '')))
.refine((n) => !isNaN(n), 'Invalid number')
.refine((n) => n >= 0, 'Must be positive')
.refine((n) => n <= 1000000, 'Too large')
.transform((n) => Math.round(n * 100))
// What type is n at each stage? Hard to tellCorrect (using pipe for clear stages):
import { z } from 'zod'
// Stage 1: Coerce string to number
const parsePrice = z.string().transform((s) => {
const cleaned = s.replace(/[$,]/g, '')
const parsed = parseFloat(cleaned)
if (isNaN(parsed)) throw new Error('Invalid number')
return parsed
})
// Stage 2: Validate number constraints
const validPrice = z.number().min(0, 'Must be positive').max(1000000, 'Too large')
// Stage 3: Transform to cents
const centsPrice = z.number().transform((n) => Math.round(n * 100))
// Pipe them together - clear data flow
const priceSchema = parsePrice.pipe(validPrice).pipe(centsPrice)
// Type at each stage is clear:
// string -> number (parsePrice)
// number -> number (validPrice)
// number -> number (centsPrice, but semantically cents)Coercion with validation:
// Without pipe - validation runs on raw input
const schema1 = z.coerce.number().min(1)
schema1.parse('') // Passes! Empty string coerces to 0, but then... wait, 0 < 1
// With pipe - validation runs on coerced value
const schema2 = z.coerce.number().pipe(z.number().min(1))
schema2.parse('') // Fails correctly: 0 is less than 1Complex data transformation:
// Input: CSV string of emails
// Output: Array of normalized, validated email objects
const emailArraySchema = z
.string()
// Stage 1: Split CSV
.transform((s) => s.split(',').map((e) => e.trim()))
// Stage 2: Validate as email array
.pipe(z.array(z.string().email()))
// Stage 3: Transform to objects
.pipe(
z.array(z.string()).transform((emails) =>
emails.map((email) => ({
address: email.toLowerCase(),
domain: email.split('@')[1],
}))
)
)
emailArraySchema.parse('John@Example.com, jane@test.com')
// [
// { address: 'john@example.com', domain: 'Example.com' },
// { address: 'jane@test.com', domain: 'test.com' }
// ]Type inference with pipe:
const schema = z.string().pipe(z.coerce.number()).pipe(z.number().positive())
type Input = z.input<typeof schema> // string
type Output = z.output<typeof schema> // number
// Each pipe stage has clear input/output typesWhen NOT to use this pattern:
- Simple single-stage validation (adds unnecessary complexity)
- When
.refine()chain is sufficient and readable
Reference: Zod API - pipe
Use preprocess() for Data Normalization
When incoming data needs normalization before validation (trimming whitespace, parsing JSON strings, converting formats), use z.preprocess(). This runs a function on the raw input before Zod's type checking, allowing you to clean data that would otherwise fail validation.
Incorrect (validation fails on unnormalized data):
import { z } from 'zod'
const userSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
config: z.object({
theme: z.string(),
}),
})
// Raw form data
const formData = {
name: ' John Doe ', // Has whitespace
email: 'JOHN@EXAMPLE.COM', // Uppercase
config: '{"theme": "dark"}', // JSON string, not object
}
userSchema.parse(formData)
// ZodError: Expected object, received string at "config"Correct (using preprocess):
import { z } from 'zod'
// Preprocess normalizes before validation
const trimmedString = z.preprocess(
(val) => (typeof val === 'string' ? val.trim() : val),
z.string()
)
const lowercaseEmail = z.preprocess(
(val) => (typeof val === 'string' ? val.toLowerCase().trim() : val),
z.string().email()
)
const jsonObject = z.preprocess(
(val) => {
if (typeof val === 'string') {
try {
return JSON.parse(val)
} catch {
return val // Let Zod report the error
}
}
return val
},
z.object({ theme: z.string() })
)
const userSchema = z.object({
name: trimmedString.pipe(z.string().min(1)),
email: lowercaseEmail,
config: jsonObject,
})
const formData = {
name: ' John Doe ',
email: 'JOHN@EXAMPLE.COM',
config: '{"theme": "dark"}',
}
const user = userSchema.parse(formData)
// { name: 'John Doe', email: 'john@example.com', config: { theme: 'dark' } }Common preprocessing patterns:
// Trim all strings
const trimmedString = z.preprocess(
(val) => (typeof val === 'string' ? val.trim() : val),
z.string()
)
// Parse numeric strings
const numericString = z.preprocess(
(val) => (typeof val === 'string' ? Number(val) : val),
z.number()
)
// Parse boolean-like values
const booleanLike = z.preprocess(
(val) => {
if (val === 'true' || val === '1' || val === 1) return true
if (val === 'false' || val === '0' || val === 0) return false
return val
},
z.boolean()
)
// Parse date strings
const dateString = z.preprocess(
(val) => (typeof val === 'string' ? new Date(val) : val),
z.date()
)
// Split comma-separated strings into arrays
const csvArray = z.preprocess(
(val) => (typeof val === 'string' ? val.split(',').map(s => s.trim()) : val),
z.array(z.string())
)Preprocess vs Transform:
// preprocess() runs BEFORE type checking
// Use for: Normalizing input format before validation
z.preprocess(val => String(val).trim(), z.string().min(1))
// transform() runs AFTER type checking
// Use for: Converting validated data to different format
z.string().transform(s => s.toUpperCase())
// Order of operations:
// 1. preprocess receives raw unknown input
// 2. Zod validates the preprocessed value
// 3. transform converts the validated valueWhen NOT to use this pattern:
- When
.coercemethods handle the conversion (simpler) - When transformation should happen after validation (use transform)
- When normalization could hide validation errors
Reference: Zod API - preprocess
Extract Shared Schemas into Reusable Modules
When the same schema pattern appears in multiple places, extract it into a shared module. This ensures consistency, reduces duplication, and makes changes propagate automatically across your codebase.
Incorrect (duplicating schemas):
// api/users.ts
import { z } from 'zod'
const userSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string().min(1),
createdAt: z.date(),
})
// api/orders.ts
import { z } from 'zod'
const orderSchema = z.object({
id: z.string().uuid(), // Duplicated
userId: z.string().uuid(), // Same pattern
items: z.array(z.object({
productId: z.string().uuid(), // Duplicated
quantity: z.number().int().positive(),
})),
createdAt: z.date(), // Duplicated
})
// api/comments.ts
import { z } from 'zod'
const commentSchema = z.object({
id: z.string().uuid(), // Same duplication
userId: z.string().uuid(),
content: z.string().min(1),
createdAt: z.date(), // Inconsistency risk
})Correct (shared schema modules):
// schemas/common.ts
import { z } from 'zod'
// Reusable ID types
export const uuid = z.string().uuid()
export type UUID = z.infer<typeof uuid>
// Timestamps
export const timestamps = z.object({
createdAt: z.date(),
updatedAt: z.date(),
})
// Base entity with ID
export const baseEntity = z.object({
id: uuid,
}).merge(timestamps)
export type BaseEntity = z.infer<typeof baseEntity>
// Pagination
export const paginationParams = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
})// schemas/user.ts
import { z } from 'zod'
import { baseEntity, uuid } from './common'
export const userSchema = baseEntity.extend({
email: z.string().email(),
name: z.string().min(1),
})
export type User = z.infer<typeof userSchema>// schemas/order.ts
import { z } from 'zod'
import { baseEntity, uuid } from './common'
const orderItemSchema = z.object({
productId: uuid,
quantity: z.number().int().positive(),
})
export const orderSchema = baseEntity.extend({
userId: uuid,
items: z.array(orderItemSchema).min(1),
total: z.number().positive(),
})
export type Order = z.infer<typeof orderSchema>Organizing schema modules:
schemas/
├── common.ts # Shared primitives and base schemas
├── user.ts # User-related schemas
├── order.ts # Order-related schemas
├── product.ts # Product-related schemas
└── index.ts # Re-exports for convenience// schemas/index.ts
export * from './common'
export * from './user'
export * from './order'
export * from './product'
// Usage
import { userSchema, orderSchema, uuid, type User } from '@/schemas'When NOT to use this pattern:
- One-off schemas used only in a single file
- When schemas look similar but have different semantics (don't over-abstract)
Reference: Zod - Type Inference
Return False Instead of Throwing in Refine
When using .refine() for custom validation, return false for invalid data instead of throwing an error. Throwing stops validation immediately, preventing Zod from collecting other validation errors. This results in poor UX where users fix one error only to discover another.
Incorrect (throwing in refine):
import { z } from 'zod'
const passwordSchema = z.object({
password: z.string().min(8),
confirmPassword: z.string(),
}).refine((data) => {
if (data.password !== data.confirmPassword) {
// Throwing stops all further validation
throw new Error('Passwords do not match')
}
return true
})
const formSchema = z.object({
email: z.string().email(),
passwords: passwordSchema,
terms: z.boolean().refine((v) => v === true, 'Must accept terms'),
})
// If passwords don't match, user never learns about other errors
formSchema.safeParse({
email: 'bad-email',
passwords: { password: '12345678', confirmPassword: 'different' },
terms: false,
})
// Only shows: "Passwords do not match"
// Hidden: "Invalid email", "Must accept terms"Correct (returning false in refine):
import { z } from 'zod'
const passwordSchema = z.object({
password: z.string().min(8),
confirmPassword: z.string(),
}).refine(
(data) => data.password === data.confirmPassword,
{ message: 'Passwords do not match', path: ['confirmPassword'] }
)
const formSchema = z.object({
email: z.string().email(),
passwords: passwordSchema,
terms: z.boolean().refine((v) => v === true, 'Must accept terms'),
})
// All errors are collected
formSchema.safeParse({
email: 'bad-email',
passwords: { password: '12345678', confirmPassword: 'different' },
terms: false,
})
// Shows all errors:
// - "Invalid email"
// - "Passwords do not match"
// - "Must accept terms"For multiple validation rules, use superRefine:
const passwordSchema = z.string().superRefine((password, ctx) => {
// Check multiple rules, report all failures
if (password.length < 8) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Password must be at least 8 characters',
})
}
if (!/[A-Z]/.test(password)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Password must contain an uppercase letter',
})
}
if (!/[0-9]/.test(password)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Password must contain a number',
})
}
// Don't return anything - issues are added via ctx
})
passwordSchema.safeParse('weak')
// All three errors reported at onceCorrect pattern for async validation:
const schema = z.object({
email: z.string().email(),
}).refine(
async (data) => {
// Return boolean, don't throw
const exists = await checkEmailExists(data.email)
return !exists
},
{ message: 'Email already registered', path: ['email'] }
)When NOT to use this pattern:
- When you need to abort validation entirely (security issues)
- When subsequent validations depend on current check passing
Reference: Zod API - Refine
Provide Custom Error Messages
Zod's default error messages are technical and confusing for end users. Provide custom messages that are clear, specific, and actionable. This dramatically improves user experience when validation fails.
Incorrect (default error messages):
import { z } from 'zod'
const signupSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
age: z.number().min(18),
})
signupSchema.parse({ email: 'bad', password: '123', age: 15 })
// ZodError issues:
// - "Invalid email"
// - "String must contain at least 8 character(s)"
// - "Number must be greater than or equal to 18"
// Users see: "String must contain at least 8 character(s)" - what string?Correct (custom error messages):
import { z } from 'zod'
const signupSchema = z.object({
email: z.string({
required_error: 'Email is required',
invalid_type_error: 'Email must be text',
}).email('Please enter a valid email address'),
password: z.string({
required_error: 'Password is required',
}).min(8, 'Password must be at least 8 characters'),
age: z.number({
required_error: 'Age is required',
invalid_type_error: 'Age must be a number',
}).min(18, 'You must be at least 18 years old'),
})
signupSchema.parse({ email: 'bad', password: '123', age: 15 })
// ZodError issues:
// - "Please enter a valid email address"
// - "Password must be at least 8 characters"
// - "You must be at least 18 years old"Message types and when they trigger:
const schema = z.string({
// When field is undefined
required_error: 'This field is required',
// When field is wrong type (e.g., number instead of string)
invalid_type_error: 'This field must be text',
// Fallback for any other error
message: 'Invalid value',
})
.min(1, 'Cannot be empty') // When length < 1
.max(100, 'Too long') // When length > 100
.email('Invalid email format') // When format failsUsing error maps for consistent messaging:
const customErrorMap: z.ZodErrorMap = (issue, ctx) => {
// Customize messages by error code
if (issue.code === z.ZodIssueCode.too_small) {
if (issue.type === 'string') {
return { message: `Must be at least ${issue.minimum} characters` }
}
if (issue.type === 'number') {
return { message: `Must be at least ${issue.minimum}` }
}
}
if (issue.code === z.ZodIssueCode.invalid_type) {
if (issue.expected === 'string') {
return { message: 'Must be text' }
}
}
// Default to Zod's message
return { message: ctx.defaultError }
}
// Apply globally
z.setErrorMap(customErrorMap)
// Or per-schema
schema.parse(data, { errorMap: customErrorMap })Good error message principles:
- Say what's wrong: "Password too short" not "Invalid password"
- Say how to fix it: "at least 8 characters" not just "too short"
- Use user's language: "email" not "string field at path .email"
- Be specific: "Must be a positive number" not "Invalid"
When NOT to use this pattern:
- Internal development scripts where technical errors are fine
- When you'll map errors to user-facing messages in the UI layer
Reference: Zod Error Customization
Implement Internationalized Error Messages
Hardcoded error messages in English exclude users who speak other languages. Use Zod's error map feature to provide localized messages based on user locale, making your application accessible globally.
Incorrect (hardcoded English messages):
import { z } from 'zod'
const userSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email address'),
age: z.number().min(18, 'You must be at least 18 years old'),
})
// French users see English errors - poor UXCorrect (localized error messages):
import { z } from 'zod'
// Translation dictionaries
const translations = {
en: {
required: 'This field is required',
invalidEmail: 'Please enter a valid email address',
tooShort: (min: number) => `Must be at least ${min} characters`,
tooYoung: (min: number) => `You must be at least ${min} years old`,
},
fr: {
required: 'Ce champ est obligatoire',
invalidEmail: 'Veuillez entrer une adresse email valide',
tooShort: (min: number) => `Doit contenir au moins ${min} caractères`,
tooYoung: (min: number) => `Vous devez avoir au moins ${min} ans`,
},
es: {
required: 'Este campo es requerido',
invalidEmail: 'Por favor ingrese un correo electrónico válido',
tooShort: (min: number) => `Debe tener al menos ${min} caracteres`,
tooYoung: (min: number) => `Debes tener al menos ${min} años`,
},
} as const
type Locale = keyof typeof translations
function createErrorMap(locale: Locale): z.ZodErrorMap {
const t = translations[locale]
return (issue, ctx) => {
switch (issue.code) {
case z.ZodIssueCode.invalid_type:
if (issue.received === 'undefined') {
return { message: t.required }
}
break
case z.ZodIssueCode.invalid_string:
if (issue.validation === 'email') {
return { message: t.invalidEmail }
}
break
case z.ZodIssueCode.too_small:
if (issue.type === 'string') {
return { message: t.tooShort(issue.minimum as number) }
}
if (issue.type === 'number') {
return { message: t.tooYoung(issue.minimum as number) }
}
break
}
return { message: ctx.defaultError }
}
}
// Usage with user's locale
const userLocale: Locale = 'fr'
const errorMap = createErrorMap(userLocale)
const userSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
age: z.number().min(18),
})
const result = userSchema.safeParse(
{ name: '', email: 'bad', age: 15 },
{ errorMap }
)
// French error messages:
// - "Ce champ est obligatoire"
// - "Veuillez entrer une adresse email valide"
// - "Vous devez avoir au moins 18 ans"Setting error map globally:
// At application startup
const userLocale = getUserLocale() // From cookie, header, etc.
z.setErrorMap(createErrorMap(userLocale))
// All schemas now use localized messagesWith i18n libraries (react-intl, i18next):
import { useIntl } from 'react-intl'
function useZodErrorMap() {
const intl = useIntl()
return (issue: z.ZodIssue, ctx: z.ErrorMapCtx) => {
switch (issue.code) {
case z.ZodIssueCode.too_small:
return {
message: intl.formatMessage(
{ id: 'validation.tooShort' },
{ min: issue.minimum }
)
}
// ...
}
return { message: ctx.defaultError }
}
}When NOT to use this pattern:
- Internal tools used only by your team
- Single-language applications
Use issue.path for Nested Error Location
When validating nested objects or arrays, issue.path tells you exactly where the error occurred. This is essential for highlighting the correct form field or providing precise error messages in complex data structures.
Incorrect (ignoring path information):
import { z } from 'zod'
const orderSchema = z.object({
customer: z.object({
name: z.string().min(1, 'Name required'),
address: z.object({
street: z.string().min(1, 'Street required'),
city: z.string().min(1, 'City required'),
}),
}),
items: z.array(z.object({
productId: z.string(),
quantity: z.number().positive('Quantity must be positive'),
})),
})
const result = orderSchema.safeParse({
customer: { name: '', address: { street: '', city: '' } },
items: [{ productId: 'abc', quantity: -1 }],
})
if (!result.success) {
// Only showing message, not WHERE the error is
result.error.issues.forEach(issue => {
console.log(issue.message) // 'Name required', 'Street required', 'Quantity must be positive'
// User: "Which quantity? Which field?"
})
}Correct (using path information):
import { z } from 'zod'
const orderSchema = z.object({
customer: z.object({
name: z.string().min(1, 'Name required'),
address: z.object({
street: z.string().min(1, 'Street required'),
city: z.string().min(1, 'City required'),
}),
}),
items: z.array(z.object({
productId: z.string(),
quantity: z.number().positive('Quantity must be positive'),
})),
})
const result = orderSchema.safeParse({
customer: { name: '', address: { street: '', city: '' } },
items: [{ productId: 'abc', quantity: -1 }],
})
if (!result.success) {
result.error.issues.forEach(issue => {
// path is an array of keys/indices
console.log(`${issue.path.join('.')}: ${issue.message}`)
// 'customer.name: Name required'
// 'customer.address.street: Street required'
// 'customer.address.city: City required'
// 'items.0.quantity: Quantity must be positive'
})
}Building field-specific error mapping:
function mapErrorsToFields(error: z.ZodError) {
const fieldErrors: Map<string, string[]> = new Map()
for (const issue of error.issues) {
const fieldPath = issue.path.join('.')
const existing = fieldErrors.get(fieldPath) ?? []
fieldErrors.set(fieldPath, [...existing, issue.message])
}
return fieldErrors
}
// Usage
const errors = mapErrorsToFields(result.error)
errors.get('customer.name') // ['Name required']
errors.get('items.0.quantity') // ['Quantity must be positive']For array items, get index from path:
const itemsWithErrors: Set<number> = new Set()
result.error.issues.forEach(issue => {
if (issue.path[0] === 'items' && typeof issue.path[1] === 'number') {
itemsWithErrors.add(issue.path[1])
}
})
// Highlight items at indices: Set { 0 }Using path with format():
const formatted = result.error.format()
// Access errors at any path level
formatted.customer?.address?.city?._errors // ['City required']
formatted.items?.[0]?.quantity?._errors // ['Quantity must be positive']When NOT to use this pattern:
- Flat objects where field name is obvious
- When using form libraries that handle path mapping
Reference: Zod Error Handling
Use flatten() for Form Error Display
ZodError.issues is an array that requires manual processing to map errors to form fields. ZodError.flatten() returns an object with fieldErrors keyed by field name, ready for form libraries and UI display.
Incorrect (manual issue processing):
import { z } from 'zod'
const formSchema = z.object({
email: z.string().email('Invalid email'),
password: z.string().min(8, 'Password too short'),
profile: z.object({
name: z.string().min(1, 'Name required'),
}),
})
function getFieldErrors(error: z.ZodError) {
const errors: Record<string, string> = {}
for (const issue of error.issues) {
// Manual path joining - error prone
const field = issue.path.join('.')
if (!errors[field]) {
errors[field] = issue.message
}
}
return errors
}
const result = formSchema.safeParse(data)
if (!result.success) {
const errors = getFieldErrors(result.error)
// { email: 'Invalid email', 'profile.name': 'Name required' }
}Correct (using flatten):
import { z } from 'zod'
const formSchema = z.object({
email: z.string().email('Invalid email'),
password: z.string().min(8, 'Password too short'),
profile: z.object({
name: z.string().min(1, 'Name required'),
}),
})
const result = formSchema.safeParse(data)
if (!result.success) {
const { formErrors, fieldErrors } = result.error.flatten()
// formErrors: string[] - top-level errors (from .refine on the object)
// fieldErrors: { [key]: string[] } - errors by field
// Ready for form display
console.log(fieldErrors)
// {
// email: ['Invalid email'],
// password: ['Password too short'],
// 'profile.name': ['Name required']
// }
}With React Hook Form:
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
const { register, formState: { errors } } = useForm({
resolver: zodResolver(formSchema),
})
// errors are already flattened by the resolver
// <input {...register('email')} />
// {errors.email && <span>{errors.email.message}</span>}Customizing flatten output:
const flattened = result.error.flatten((issue) => ({
message: issue.message,
code: issue.code,
}))
// fieldErrors now contains custom objects
// {
// email: [{ message: 'Invalid email', code: 'invalid_string' }],
// }For deeply nested objects, use format():
const result = formSchema.safeParse(data)
if (!result.success) {
const formatted = result.error.format()
// {
// _errors: [],
// email: { _errors: ['Invalid email'] },
// profile: {
// _errors: [],
// name: { _errors: ['Name required'] }
// }
// }
// Access nested errors naturally
formatted.profile?.name?._errors // ['Name required']
}When NOT to use this pattern:
- When you need access to full issue metadata (code, path as array)
- When using a form library that expects different error format
Reference: Zod Error Handling
Use Discriminated Unions for Type Narrowing
When a field's type depends on another field's value (e.g., type: 'success' means data exists, type: 'error' means error exists), use z.discriminatedUnion(). This enables TypeScript's automatic type narrowing and Zod's optimized O(1) parsing instead of trying each variant.
Incorrect (regular union - no automatic narrowing):
import { z } from 'zod'
const successSchema = z.object({
type: z.literal('success'),
data: z.object({ id: z.string() }),
})
const errorSchema = z.object({
type: z.literal('error'),
message: z.string(),
})
// Regular union - Zod tries each option in order
const responseSchema = z.union([successSchema, errorSchema])
type Response = z.infer<typeof responseSchema>
function handleResponse(response: Response) {
// TypeScript doesn't narrow automatically
if (response.type === 'success') {
response.data // Error: Property 'data' does not exist on type 'Response'
// Must cast or use type guards
}
}Correct (discriminated union):
import { z } from 'zod'
const successSchema = z.object({
type: z.literal('success'),
data: z.object({ id: z.string() }),
})
const errorSchema = z.object({
type: z.literal('error'),
message: z.string(),
})
// Discriminated union - Zod uses 'type' field for O(1) dispatch
const responseSchema = z.discriminatedUnion('type', [
successSchema,
errorSchema,
])
type Response = z.infer<typeof responseSchema>
function handleResponse(response: Response) {
// TypeScript narrows automatically!
if (response.type === 'success') {
response.data.id // Works - TypeScript knows data exists
} else {
response.message // Works - TypeScript knows message exists
}
}Common use cases:
// API responses
const apiResponse = z.discriminatedUnion('status', [
z.object({ status: z.literal('success'), data: z.unknown() }),
z.object({ status: z.literal('error'), error: z.string(), code: z.number() }),
z.object({ status: z.literal('loading') }),
])
// Event types
const event = z.discriminatedUnion('type', [
z.object({ type: z.literal('click'), x: z.number(), y: z.number() }),
z.object({ type: z.literal('keypress'), key: z.string() }),
z.object({ type: z.literal('scroll'), delta: z.number() }),
])
// Database records with polymorphic types
const notification = z.discriminatedUnion('channel', [
z.object({ channel: z.literal('email'), address: z.string().email() }),
z.object({ channel: z.literal('sms'), phoneNumber: z.string() }),
z.object({ channel: z.literal('push'), deviceToken: z.string() }),
])Type-safe handling:
const paymentSchema = z.discriminatedUnion('method', [
z.object({
method: z.literal('card'),
cardNumber: z.string(),
expiryDate: z.string(),
}),
z.object({
method: z.literal('bank'),
accountNumber: z.string(),
routingNumber: z.string(),
}),
z.object({
method: z.literal('crypto'),
walletAddress: z.string(),
}),
])
type Payment = z.infer<typeof paymentSchema>
function processPayment(payment: Payment) {
switch (payment.method) {
case 'card':
return chargeCard(payment.cardNumber, payment.expiryDate)
case 'bank':
return initiateBankTransfer(payment.accountNumber, payment.routingNumber)
case 'crypto':
return sendCrypto(payment.walletAddress)
// TypeScript exhaustiveness check - no default needed
}
}When NOT to use this pattern:
- When variants don't share a common discriminator field
- When the discriminator isn't a literal type (use regular union)
Reference: Zod API - Discriminated Unions
Use extend() for Adding Fields
When building on existing schemas, use .extend() to add new fields rather than manually spreading. Extend preserves type information, allows overriding existing fields, and keeps the schema relationship explicit.
Incorrect (manual object spreading):
import { z } from 'zod'
const baseUserSchema = z.object({
id: z.string(),
name: z.string(),
})
// Manual spreading loses Zod's schema relationship
const adminUserSchema = z.object({
...baseUserSchema.shape, // Accessing internal .shape
role: z.literal('admin'),
permissions: z.array(z.string()),
})
// Problems:
// 1. If baseUserSchema changes, TypeScript might not catch issues
// 2. Can't override fields easily
// 3. Loses schema methods and metadataCorrect (using extend):
import { z } from 'zod'
const baseUserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
})
// Extend to add fields
const adminUserSchema = baseUserSchema.extend({
role: z.literal('admin'),
permissions: z.array(z.string()),
})
type AdminUser = z.infer<typeof adminUserSchema>
// {
// id: string;
// name: string;
// email: string;
// role: 'admin';
// permissions: string[];
// }
// Override existing fields
const strictEmailSchema = baseUserSchema.extend({
email: z.string().email().endsWith('@company.com'), // Stricter validation
})Building hierarchies with extend:
// Base entity with common fields
const entitySchema = z.object({
id: z.string().uuid(),
createdAt: z.date(),
updatedAt: z.date(),
})
// User extends entity
const userSchema = entitySchema.extend({
email: z.string().email(),
name: z.string(),
})
// Product extends entity
const productSchema = entitySchema.extend({
name: z.string(),
price: z.number().positive(),
sku: z.string(),
})
// Order extends entity with references
const orderSchema = entitySchema.extend({
userId: z.string().uuid(),
items: z.array(z.object({
productId: z.string().uuid(),
quantity: z.number().int().positive(),
})),
total: z.number().positive(),
})Combining extend with other methods:
const baseSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string(),
})
// Create input: no id, add password
const createSchema = baseSchema
.omit({ id: true })
.extend({
password: z.string().min(8),
})
// Update input: all optional except id
const updateSchema = baseSchema
.partial()
.extend({
id: z.string(), // Override to make required
})Merge for combining independent schemas:
const addressSchema = z.object({
street: z.string(),
city: z.string(),
})
const contactSchema = z.object({
email: z.string().email(),
phone: z.string(),
})
// Merge combines two schemas (both required)
const customerSchema = addressSchema.merge(contactSchema)
// { street: string; city: string; email: string; phone: string }When NOT to use this pattern:
- When schemas are genuinely independent (use merge or intersection)
- When you need to remove fields (use omit)
Reference: Zod API - extend
Distinguish optional() from nullable()
.optional() allows undefined (field can be missing), while .nullable() allows null (field must be present but can be null). Choosing the wrong one causes subtle bugs in database operations, JSON serialization, and API contracts.
Incorrect (confusing optional and nullable):
import { z } from 'zod'
const userSchema = z.object({
name: z.string(),
// Intended: field might not exist
nickname: z.string().nullable(), // Wrong! Requires field to be present
// Intended: field exists but might be null
deletedAt: z.date().optional(), // Wrong! Allows field to be missing
})
// This fails - nickname is required
userSchema.parse({ name: 'John' })
// ZodError: Required at "nickname"
// This passes but loses semantic meaning
userSchema.parse({ name: 'John', nickname: null, deletedAt: undefined })
// Is deletedAt undefined because not deleted, or because data is incomplete?Correct (using optional and nullable deliberately):
import { z } from 'zod'
const userSchema = z.object({
name: z.string(),
// optional() - field might not exist in the object
nickname: z.string().optional(),
// Type: string | undefined
// nullable() - field must exist, but value can be null
deletedAt: z.date().nullable(),
// Type: Date | null
})
// Field can be omitted
userSchema.parse({ name: 'John', deletedAt: null }) // Valid
// Field must be present (even if null)
userSchema.parse({ name: 'John', nickname: 'Johnny' })
// ZodError: Required at "deletedAt"
// Correct usage
userSchema.parse({
name: 'John',
nickname: 'Johnny', // Or omit entirely
deletedAt: null, // Must be present, null means "not deleted"
})When to use each:
// optional() - field may not exist
// Use for: Optional form fields, sparse updates, optional config
z.object({
bio: z.string().optional(), // User might not have filled this
middleName: z.string().optional(), // Not everyone has one
})
// nullable() - field exists but value can be null
// Use for: Database nullable columns, "cleared" values, explicit absence
z.object({
deletedAt: z.date().nullable(), // null = not deleted, Date = when deleted
parentId: z.string().nullable(), // null = root node, string = has parent
approvedBy: z.string().nullable(), // null = pending, string = approver
})
// nullish() - either undefined or null
// Use for: Lenient APIs, legacy data, optional nullable DB columns
z.object({
legacyField: z.string().nullish(), // string | null | undefined
})API response patterns:
// API includes null for "no value" (good for explicit absence)
const apiResponseSchema = z.object({
data: z.object({
user: z.object({
name: z.string(),
avatar: z.string().nullable(), // null = no avatar set
}).nullable(), // null = user not found
}),
})
// Type: { data: { user: { name: string; avatar: string | null } | null } }
// Partial updates send only changed fields
const updateSchema = z.object({
name: z.string().optional(), // Omitted = don't change
avatar: z.string().nullable().optional(), // null = clear avatar
})When NOT to use this pattern:
- When interacting with systems that treat null and undefined as equivalent
- When using nullish() for maximum flexibility is acceptable
Reference: Zod API - optional/nullable
Use partial() for Update Schemas
When handling PATCH/PUT updates, you need a schema where all fields are optional. Instead of duplicating the schema with optional fields, use .partial() to derive it from your base schema. This keeps both schemas in sync automatically.
Incorrect (duplicating schemas):
import { z } from 'zod'
// Base schema
const userSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
age: z.number().int().positive(),
role: z.enum(['admin', 'user']),
})
// Manually duplicated for updates - will drift!
const updateUserSchema = z.object({
name: z.string().min(1).optional(),
email: z.string().email().optional(),
age: z.number().int().positive().optional(),
// Forgot to add role - schemas out of sync!
})
// Later, you add a field to userSchema but forget updateUserSchema
// Now updates silently ignore the new fieldCorrect (using partial):
import { z } from 'zod'
// Base schema - single source of truth
const userSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
age: z.number().int().positive(),
role: z.enum(['admin', 'user']),
})
// All fields optional for updates
const updateUserSchema = userSchema.partial()
type User = z.infer<typeof userSchema>
// { name: string; email: string; age: number; role: 'admin' | 'user' }
type UpdateUser = z.infer<typeof updateUserSchema>
// { name?: string; email?: string; age?: number; role?: 'admin' | 'user' }
// Validate partial updates
updateUserSchema.parse({ email: 'new@example.com' }) // Valid
updateUserSchema.parse({}) // Valid - all fields optionalPartial specific fields only:
// Only name and email are optional for updates
const updateUserSchema = userSchema.partial({
name: true,
email: true,
})
type UpdateUser = z.infer<typeof updateUserSchema>
// { name?: string; email?: string; age: number; role: 'admin' | 'user' }
// age and role still requiredDeep partial for nested objects:
const addressSchema = z.object({
street: z.string(),
city: z.string(),
country: z.string(),
})
const userSchema = z.object({
name: z.string(),
address: addressSchema,
})
// .partial() only makes top-level fields optional
const shallowPartial = userSchema.partial()
// { name?: string; address?: { street: string; city: string; country: string } }
// If address is provided, all its fields are still required!
// Use deepPartial for nested optionality
const deepPartialSchema = userSchema.deepPartial()
// { name?: string; address?: { street?: string; city?: string; country?: string } }Combining with required() for create vs update:
const baseSchema = z.object({
id: z.string().uuid(),
name: z.string(),
email: z.string().email(),
createdAt: z.date(),
})
// Create: id and createdAt are generated, rest required
const createSchema = baseSchema.omit({ id: true, createdAt: true })
// Update: all user-editable fields optional
const updateSchema = baseSchema.partial().omit({ id: true, createdAt: true })When NOT to use this pattern:
- When update logic differs significantly from create (different validations)
- When using GraphQL with explicit input types
Reference: Zod API - partial
Use pick() and omit() for Schema Variants
When you need different views of the same data (public vs private, create vs response), use .pick() and .omit() instead of duplicating fields. This ensures derived schemas stay in sync with the base schema.
Incorrect (duplicating for variants):
import { z } from 'zod'
// Full user schema
const userSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
passwordHash: z.string(),
name: z.string(),
createdAt: z.date(),
isAdmin: z.boolean(),
})
// Public view - manually duplicated
const publicUserSchema = z.object({
id: z.string().uuid(),
name: z.string(),
// Forgot email - now users can't see it
// Added avatar field - doesn't exist in base schema
avatar: z.string().optional(),
})
// Create input - manually duplicated
const createUserSchema = z.object({
email: z.string().email(),
password: z.string().min(8), // Different from passwordHash
name: z.string(),
// Missing isAdmin - can't set on create? Intentional?
})Correct (using pick and omit):
import { z } from 'zod'
// Full user schema - single source of truth
const userSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
passwordHash: z.string(),
name: z.string(),
createdAt: z.date(),
isAdmin: z.boolean(),
})
// Public view - explicitly pick public fields
const publicUserSchema = userSchema.pick({
id: true,
email: true,
name: true,
})
type PublicUser = z.infer<typeof publicUserSchema>
// { id: string; email: string; name: string }
// API response - omit sensitive fields
const userResponseSchema = userSchema.omit({
passwordHash: true,
})
type UserResponse = z.infer<typeof userResponseSchema>
// { id: string; email: string; name: string; createdAt: Date; isAdmin: boolean }
// Create input - omit generated fields
const createUserInputSchema = userSchema
.omit({ id: true, createdAt: true, passwordHash: true })
.extend({
password: z.string().min(8), // Add password (different from hash)
})
type CreateUserInput = z.infer<typeof createUserInputSchema>
// { email: string; name: string; isAdmin: boolean; password: string }Common patterns:
// Database row → API response (hide internal fields)
const dbRowSchema = z.object({
id: z.number(),
public_id: z.string().uuid(),
email: z.string(),
password_hash: z.string(),
internal_notes: z.string(),
created_at: z.date(),
})
const apiResponseSchema = dbRowSchema.omit({
id: true, // Internal DB id
password_hash: true, // Sensitive
internal_notes: true, // Staff only
})
// Form data → Database insert (add generated fields)
const formSchema = z.object({
title: z.string(),
content: z.string(),
})
const dbInsertSchema = formSchema.extend({
id: z.string().uuid(),
authorId: z.string().uuid(),
createdAt: z.date(),
updatedAt: z.date(),
})Chaining operations:
const baseSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string(),
role: z.enum(['admin', 'user']),
secret: z.string(),
})
// Combine pick, omit, partial, extend
const updateSchema = baseSchema
.omit({ id: true, secret: true }) // Remove immutable/sensitive
.partial() // Make all optional for updates
.extend({
updatedAt: z.date().optional(), // Add update timestamp
})When NOT to use this pattern:
- When derived schemas need different validation rules (not just different fields)
- When the relationship between schemas is not subset/superset
Reference: Zod API - pick/omit
Choose strict() vs strip() for Unknown Keys
By default, Zod objects use .strip() behavior, silently removing unrecognized keys. This can hide schema/data mismatches. Use .strict() to reject unknown keys (catching errors) or explicitly use .strip() to document the intention.
Default behavior (strip - silent removal):
import { z } from 'zod'
const userSchema = z.object({
id: z.string(),
name: z.string(),
})
const input = {
id: '123',
name: 'John',
role: 'admin', // Extra field
secretToken: 'abc123', // Another extra field
}
const user = userSchema.parse(input)
// { id: '123', name: 'John' }
// Extra fields silently removed - was this intentional?Using strict() to catch schema mismatches:
import { z } from 'zod'
const userSchema = z.object({
id: z.string(),
name: z.string(),
}).strict()
const input = {
id: '123',
name: 'John',
role: 'admin',
}
userSchema.parse(input)
// ZodError: Unrecognized key(s) in object: 'role'
// This catches:
// - Client sending fields the server doesn't expect
// - Schema out of sync with actual data structure
// - Typos in field namesWhen to use each mode:
// strict() - Catch unexpected data (API contracts)
const apiRequestSchema = z.object({
action: z.string(),
payload: z.unknown(),
}).strict() // Fail if client sends unknown fields
// strip() - Clean up data (explicit intention)
const dbInsertSchema = z.object({
name: z.string(),
email: z.string(),
}).strip() // Explicitly remove metadata before insert
// passthrough() - Keep everything (pass-through proxy)
const proxySchema = z.object({
id: z.string(),
}).passthrough() // Keep fields we don't validate
const input = { id: '123', extra: 'data' }
proxySchema.parse(input) // { id: '123', extra: 'data' }Choosing the right mode:
| Mode | Behavior | Use When |
|---|---|---|
.strict() | Reject unknown keys | API contracts, security-sensitive, debugging |
.strip() (default) | Remove unknown keys | General validation, data cleaning |
.passthrough() | Keep unknown keys | Proxying, partial validation |
Handling specific unknown keys:
const schema = z.object({
id: z.string(),
name: z.string(),
}).catchall(z.unknown()) // Allow any additional fields of any type
// Or restrict additional fields to specific type
const metadataSchema = z.object({
id: z.string(),
}).catchall(z.string()) // Only allow string extrasWhen NOT to use this pattern:
.strict(): When forwarding data to another system that may add fields.passthrough(): When you need to ensure only known fields are stored
Reference: Zod API - Objects
Use parseAsync for Async Refinements
If your schema uses refine() or superRefine() with async validation (like database lookups), you must use parseAsync() or safeParseAsync(). Using synchronous parse() with async refinements throws an error.
Incorrect (sync parse with async refinement):
import { z } from 'zod'
const userSchema = z.object({
email: z.string().email(),
username: z.string().min(3),
}).refine(
async (data) => {
// Async database check
const exists = await db.users.findByEmail(data.email)
return !exists
},
{ message: 'Email already registered' }
)
// This throws an error!
const user = userSchema.parse(formData)
// Error: Async refinement encountered during synchronous parse operation.
// Use .parseAsync instead.Correct (using parseAsync):
import { z } from 'zod'
const userSchema = z.object({
email: z.string().email(),
username: z.string().min(3),
}).refine(
async (data) => {
const exists = await db.users.findByEmail(data.email)
return !exists
},
{ message: 'Email already registered' }
)
// Use parseAsync for async refinements
const user = await userSchema.parseAsync(formData)
// Or safeParseAsync for error handling
const result = await userSchema.safeParseAsync(formData)
if (!result.success) {
console.log(result.error.issues)
}Async transforms also require parseAsync:
const enrichedUserSchema = z.object({
userId: z.string().uuid(),
}).transform(async (data) => {
// Async data enrichment
const user = await db.users.findById(data.userId)
return {
...data,
email: user.email,
name: user.name,
}
})
// Must use parseAsync
const enrichedUser = await enrichedUserSchema.parseAsync({ userId: '123' })Pattern for API routes:
import { z } from 'zod'
import { NextRequest, NextResponse } from 'next/server'
const registerSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
}).superRefine(async (data, ctx) => {
const existingUser = await db.users.findByEmail(data.email)
if (existingUser) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['email'],
message: 'Email already registered',
})
}
})
export async function POST(req: NextRequest) {
const body = await req.json()
// Always use safeParseAsync with async schemas
const result = await registerSchema.safeParseAsync(body)
if (!result.success) {
return NextResponse.json({ errors: result.error.issues }, { status: 400 })
}
// Proceed with registration
}When NOT to use this pattern:
- Schemas with only synchronous validation (use parse/safeParse)
- When async validation can be moved outside Zod (validate, then check)
Reference: Zod API - parseAsync
Avoid Double Validation
Once data is validated by Zod, trust the result. Re-validating the same data in multiple layers doubles CPU usage and adds latency. Pass the typed result through your application instead.
Incorrect (validating at every layer):
import { z } from 'zod'
const userSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string(),
})
// Controller validates
export async function POST(req: NextRequest) {
const body = await req.json()
const user = userSchema.parse(body) // First parse
return await userService.create(user)
}
// Service validates again
const userService = {
async create(data: unknown) {
const user = userSchema.parse(data) // Second parse - redundant
return await userRepository.insert(user)
}
}
// Repository validates again
const userRepository = {
async insert(data: unknown) {
const user = userSchema.parse(data) // Third parse - wasteful
return await db.users.create({ data: user })
}
}Correct (validate once, pass typed data):
import { z } from 'zod'
const userSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string(),
})
type User = z.infer<typeof userSchema>
// Controller validates at boundary
export async function POST(req: NextRequest) {
const body = await req.json()
const result = userSchema.safeParse(body)
if (!result.success) {
return NextResponse.json({ errors: result.error.issues }, { status: 400 })
}
// Pass validated, typed data
return await userService.create(result.data)
}
// Service receives typed data, no re-validation needed
const userService = {
async create(user: User) {
// user is guaranteed to match schema
return await userRepository.insert(user)
}
}
// Repository receives typed data
const userRepository = {
async insert(user: User) {
return await db.users.create({ data: user })
}
}When you might validate at multiple layers:
// Different schemas for different layers
const apiUserSchema = z.object({
email: z.string().email(),
password: z.string().min(8), // Only in API layer
})
const dbUserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
passwordHash: z.string(), // Transformed before storage
})
// API validates input format
export async function POST(req: NextRequest) {
const input = apiUserSchema.parse(await req.json())
const user = await userService.create(input)
return NextResponse.json(user)
}
// Service transforms and validates for storage
const userService = {
async create(input: z.infer<typeof apiUserSchema>) {
const dbUser = dbUserSchema.parse({
id: crypto.randomUUID(),
email: input.email,
passwordHash: await hash(input.password),
})
return await userRepository.insert(dbUser)
}
}When NOT to use this pattern:
- When schemas differ between layers (API vs DB shape)
- When data crosses trust boundaries (external service response)
- During development when debugging data flow
Reference: Zod Performance
Handle All Validation Issues Not Just First
Zod collects all validation failures, not just the first one. When displaying errors to users, show all issues so they can fix everything at once instead of playing whack-a-mole with one error at a time.
Incorrect (showing only first error):
import { z } from 'zod'
const formSchema = z.object({
email: z.string().email('Invalid email'),
password: z.string().min(8, 'Password must be 8+ characters'),
confirmPassword: z.string(),
age: z.number().min(18, 'Must be 18 or older'),
})
function validateForm(data: unknown) {
const result = formSchema.safeParse(data)
if (!result.success) {
// Only shows first error - terrible UX
return { error: result.error.issues[0].message }
}
return { data: result.data }
}
// User submits empty form
validateForm({})
// Returns: { error: 'Invalid email' }
// User fixes email, submits again
// Returns: { error: 'Password must be 8+ characters' }
// User fixes password, submits again...
// 4 round trips to fix 4 errors!Correct (showing all errors):
import { z } from 'zod'
const formSchema = z.object({
email: z.string().email('Invalid email'),
password: z.string().min(8, 'Password must be 8+ characters'),
confirmPassword: z.string(),
age: z.number().min(18, 'Must be 18 or older'),
})
function validateForm(data: unknown) {
const result = formSchema.safeParse(data)
if (!result.success) {
// Collect errors by field for form display
const fieldErrors: Record<string, string[]> = {}
for (const issue of result.error.issues) {
const field = issue.path.join('.')
if (!fieldErrors[field]) {
fieldErrors[field] = []
}
fieldErrors[field].push(issue.message)
}
return { errors: fieldErrors }
}
return { data: result.data }
}
// User submits empty form
validateForm({})
// Returns: {
// errors: {
// email: ['Invalid email'],
// password: ['Password must be 8+ characters'],
// confirmPassword: ['Required'],
// age: ['Expected number, received undefined']
// }
// }
// User sees ALL errors, fixes everything, submits once!Using flatten() for simpler error structure:
const result = formSchema.safeParse(data)
if (!result.success) {
const flattened = result.error.flatten()
// {
// formErrors: [], // Top-level errors
// fieldErrors: {
// email: ['Invalid email'],
// password: ['Password must be 8+ characters'],
// ...
// }
// }
return { errors: flattened.fieldErrors }
}With React Hook Form integration:
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
const form = useForm({
resolver: zodResolver(formSchema),
// All errors are automatically collected and displayed
})When NOT to use this pattern:
- Rate-limited APIs where you want to fail fast on first error
- Large batch processing where full validation is expensive
Reference: Zod Error Handling
Never Trust JSON.parse Output
JSON.parse() returns any (or unknown in strict mode), providing no type guarantees. Always validate JSON output with Zod before using it, even if you control the JSON source. This catches corruption, version mismatches, and ensures type safety.
Incorrect (trusting JSON.parse):
// JSON.parse returns any - no type safety
const config = JSON.parse(fs.readFileSync('config.json', 'utf-8'))
// config is 'any' - TypeScript allows anything
// This might crash at runtime if structure changed
console.log(config.database.host) // TypeError: Cannot read property 'host' of undefined
// API response - also unvalidated
const response = await fetch('/api/user')
const user = await response.json() // any type
console.log(user.name.toUpperCase()) // Crash if name is null/undefinedCorrect (validate after JSON.parse):
import { z } from 'zod'
const configSchema = z.object({
database: z.object({
host: z.string(),
port: z.number(),
name: z.string(),
}),
api: z.object({
key: z.string(),
timeout: z.number().default(5000),
}),
})
// Parse JSON then validate
const rawConfig = JSON.parse(fs.readFileSync('config.json', 'utf-8'))
const config = configSchema.parse(rawConfig)
// config is fully typed: { database: { host: string, ... }, ... }
// API response validation
const userSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
})
const response = await fetch('/api/user')
const rawUser = await response.json()
const user = userSchema.parse(rawUser)
// user is fully typed and validatedHelper for validated JSON parsing:
function parseJSON<T>(schema: z.ZodType<T>, json: string): T {
return schema.parse(JSON.parse(json))
}
function safeParseJSON<T>(schema: z.ZodType<T>, json: string) {
try {
return { success: true as const, data: schema.parse(JSON.parse(json)) }
} catch (error) {
if (error instanceof SyntaxError) {
return { success: false as const, error: 'Invalid JSON' }
}
if (error instanceof z.ZodError) {
return { success: false as const, error: error.issues }
}
throw error
}
}
// Usage
const config = parseJSON(configSchema, fs.readFileSync('config.json', 'utf-8'))Validate localStorage/sessionStorage:
const cartSchema = z.array(z.object({
productId: z.string(),
quantity: z.number().int().positive(),
}))
function getCart() {
const raw = localStorage.getItem('cart')
if (!raw) return []
const result = cartSchema.safeParse(JSON.parse(raw))
if (!result.success) {
// Corrupted cart data - clear it
localStorage.removeItem('cart')
return []
}
return result.data
}When NOT to use this pattern:
- When you genuinely need to pass through arbitrary JSON without processing
Reference: Zod API - parse
Use safeParse() for User Input
parse() throws a ZodError when validation fails, which crashes your application if not caught. safeParse() returns a result object that you can inspect without try/catch. Use safeParse() for any user-provided or external data.
Incorrect (parse without error handling):
import { z } from 'zod'
import { NextRequest, NextResponse } from 'next/server'
const createUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1),
})
export async function POST(req: NextRequest) {
const body = await req.json()
// If validation fails, this throws and crashes the handler
const user = createUserSchema.parse(body)
// Never reached if parse throws
await db.users.create({ data: user })
return NextResponse.json({ success: true })
}
// Result: 500 Internal Server Error with stack traceCorrect (using safeParse):
import { z } from 'zod'
import { NextRequest, NextResponse } from 'next/server'
const createUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1),
})
export async function POST(req: NextRequest) {
const body = await req.json()
const result = createUserSchema.safeParse(body)
if (!result.success) {
// Return structured error response
return NextResponse.json(
{ error: 'Validation failed', issues: result.error.issues },
{ status: 400 }
)
}
// result.data is typed correctly
await db.users.create({ data: result.data })
return NextResponse.json({ success: true })
}The result object structure:
// Success case
{ success: true, data: T }
// Error case
{ success: false, error: ZodError }
// Type narrowing works automatically
if (result.success) {
result.data // T - fully typed
} else {
result.error // ZodError
result.error.issues // Array of validation issues
}When parse() is acceptable:
// Internal data you control - parse is fine
const config = configSchema.parse(JSON.parse(process.env.CONFIG))
// Test assertions - parse throws helpful errors
expect(() => schema.parse(invalidData)).toThrow()
// Schema development - see errors immediately
schema.parse(testData) // See what fails during developmentWhen NOT to use this pattern:
- Internal configuration parsing where invalid data should crash early
- Tests where you want exceptions to fail the test
- Scripts where you want to see the full error
Reference: Zod API - safeParse
Validate at System Boundaries
Validate external data immediately when it enters your system—at API endpoints, form handlers, message queue consumers, and configuration loaders. Validating deep in business logic allows corrupt data to propagate and makes debugging harder.
Incorrect (validating deep in business logic):
import { z } from 'zod'
// No validation at API boundary
export async function POST(req: NextRequest) {
const body = await req.json()
// Raw unknown data passed through
return await processOrder(body)
}
async function processOrder(data: unknown) {
// Data passed around unvalidated
const items = await calculateTotals(data)
return await chargeCustomer(data, items)
}
async function calculateTotals(data: unknown) {
// Finally validating way too late
const order = orderSchema.parse(data) // Throws here, far from entry point
// ...
}
// Hard to trace where bad data came fromCorrect (validating at boundary):
import { z } from 'zod'
const orderSchema = z.object({
customerId: z.string().uuid(),
items: z.array(z.object({
productId: z.string(),
quantity: z.number().int().positive(),
})).min(1),
shippingAddress: z.object({
street: z.string(),
city: z.string(),
country: z.string(),
}),
})
type Order = z.infer<typeof orderSchema>
// Validate immediately at boundary
export async function POST(req: NextRequest) {
const body = await req.json()
const result = orderSchema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{ error: 'Invalid order', issues: result.error.issues },
{ status: 400 }
)
}
// Now data is validated and typed
return await processOrder(result.data)
}
// Business logic receives typed, validated data
async function processOrder(order: Order) {
// order is guaranteed to match schema
const items = await calculateTotals(order)
return await chargeCustomer(order, items)
}
async function calculateTotals(order: Order) {
// No validation needed - type guarantees shape
return order.items.map(item => ({
...item,
total: item.quantity * getPrice(item.productId),
}))
}Boundaries to validate:
// API endpoints
export async function POST(req: NextRequest) {
const data = await req.json()
const validated = requestSchema.safeParse(data)
// ...
}
// Message queue consumers
async function handleMessage(rawMessage: string) {
const data = JSON.parse(rawMessage)
const validated = messageSchema.safeParse(data)
// ...
}
// Configuration loading
const config = configSchema.parse(JSON.parse(process.env.CONFIG!))
// External API responses
const response = await fetch('/api/users')
const data = await response.json()
const users = usersResponseSchema.parse(data)When NOT to use this pattern:
- Internal function calls with already-validated data
- Performance-critical hot paths (validate once, trust afterward)
Optimize Large Array Validation
Validating large arrays (thousands of items) can become a performance bottleneck. For batch imports, streaming data, or large datasets, consider strategies like early exit, sampling, or batched validation.
Baseline performance:
import { z } from 'zod'
const itemSchema = z.object({
id: z.string(),
value: z.number(),
})
const arraySchema = z.array(itemSchema)
// 10,000 items: ~100ms
// 100,000 items: ~1000ms
arraySchema.parse(largeArray)Early exit on first error:
import { z } from 'zod'
function validateArrayFastFail<T>(
schema: z.ZodType<T>,
items: unknown[]
): { success: true; data: T[] } | { success: false; error: z.ZodError; index: number } {
const validated: T[] = []
for (let i = 0; i < items.length; i++) {
const result = schema.safeParse(items[i])
if (!result.success) {
return { success: false, error: result.error, index: i }
}
validated.push(result.data)
}
return { success: true, data: validated }
}
// Stops at first invalid item instead of validating allSample validation for large datasets:
function validateSample<T>(
schema: z.ZodType<T>,
items: unknown[],
sampleSize: number = 100
): { valid: boolean; sampleErrors?: z.ZodIssue[] } {
// Validate random sample
const indices = new Set<number>()
while (indices.size < Math.min(sampleSize, items.length)) {
indices.add(Math.floor(Math.random() * items.length))
}
const errors: z.ZodIssue[] = []
for (const i of indices) {
const result = schema.safeParse(items[i])
if (!result.success) {
errors.push(...result.error.issues)
}
}
return errors.length > 0
? { valid: false, sampleErrors: errors }
: { valid: true }
}
// Check 100 random items from 100,000 - very fast
const check = validateSample(itemSchema, hugeArray)Batched validation with progress:
async function validateInBatches<T>(
schema: z.ZodType<T>,
items: unknown[],
batchSize: number = 1000,
onProgress?: (percent: number) => void
): Promise<z.SafeParseReturnType<unknown, T[]>> {
const validated: T[] = []
const errors: z.ZodIssue[] = []
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, i + batchSize)
// Validate batch
for (let j = 0; j < batch.length; j++) {
const result = schema.safeParse(batch[j])
if (result.success) {
validated.push(result.data)
} else {
errors.push(...result.error.issues.map(issue => ({
...issue,
path: [i + j, ...issue.path],
})))
}
}
// Report progress and yield to event loop
onProgress?.(Math.min(100, ((i + batchSize) / items.length) * 100))
await new Promise(resolve => setTimeout(resolve, 0))
}
if (errors.length > 0) {
return { success: false, error: new z.ZodError(errors) }
}
return { success: true, data: validated }
}
// Use with progress reporting
await validateInBatches(itemSchema, largeArray, 1000, (percent) => {
console.log(`Validating: ${percent.toFixed(1)}%`)
})Streaming validation:
async function* validateStream<T>(
schema: z.ZodType<T>,
items: AsyncIterable<unknown>
): AsyncGenerator<T, void, unknown> {
for await (const item of items) {
yield schema.parse(item) // Throws on invalid
}
}
// Process items as they arrive
for await (const validItem of validateStream(itemSchema, dataStream)) {
await processItem(validItem)
}When NOT to use this pattern:
- Small arrays (< 1000 items) - standard validation is fine
- When all items must be validated for correctness guarantees
Reference: Zod Performance
Avoid Dynamic Schema Creation in Hot Paths
Zod 4 uses JIT (Just-In-Time) compilation to speed up repeated parsing, but this makes initial schema creation slower. Avoid creating schemas inside loops or frequently-called functions—pre-create them instead.
Incorrect (schema creation in hot path):
import { z } from 'zod'
async function validateBatch(items: unknown[]) {
const results = []
for (const item of items) {
// Schema created for EACH item - slow!
const schema = z.object({
id: z.string(),
value: z.number(),
})
results.push(schema.safeParse(item))
}
return results
}
// 1000 items = 1000 schema creations = ~150ms overheadCorrect (pre-created schema):
import { z } from 'zod'
// Schema created ONCE
const itemSchema = z.object({
id: z.string(),
value: z.number(),
})
async function validateBatch(items: unknown[]) {
// Reuse the same schema instance
return items.map(item => itemSchema.safeParse(item))
}
// 1000 items = 1 schema creation + 1000 fast parsesDynamic schemas with caching:
import { z } from 'zod'
// Cache for dynamically-configured schemas
const schemaCache = new WeakMap<object, z.ZodType>()
function getSchemaForConfig(config: { fields: string[] }) {
// Check cache first
if (schemaCache.has(config)) {
return schemaCache.get(config)!
}
// Create and cache
const shape: Record<string, z.ZodString> = {}
for (const field of config.fields) {
shape[field] = z.string()
}
const schema = z.object(shape)
schemaCache.set(config, schema)
return schema
}
// Subsequent calls with same config reuse cached schemaLazy schema creation:
import { z } from 'zod'
// Schema created only when first used
let _userSchema: z.ZodObject<any> | null = null
function getUserSchema() {
if (!_userSchema) {
_userSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
profile: z.object({
name: z.string(),
avatar: z.string().url().optional(),
}),
})
}
return _userSchema
}
// Or use a getter
const schemas = {
_user: null as z.ZodType | null,
get user() {
if (!this._user) {
this._user = z.object({ /* ... */ })
}
return this._user
}
}Benchmark considerations:
// Zod 4 JIT compilation:
// - Schema creation: ~0.15ms per schema
// - First parse: triggers JIT compile
// - Subsequent parses: 7-14x faster
// For schemas used once:
// - Creation + parse: ~0.15ms + first-parse overhead
// - Consider if validation is even needed
// For schemas used many times:
// - Create once, parse many: optimal
// - JIT compilation amortized over all parsesWhen NOT to use this pattern:
- One-off validation where schema is used once
- Dynamically generated forms where fields change per request
- Test files where performance doesn't matter
Reference: Zod v4 Performance
Cache Schema Instances
Schema creation has overhead. Creating schemas inside render functions or on every function call wastes CPU cycles. Define schemas at module level or memoize them so they're created once and reused.
Incorrect (creating schema every render):
import { z } from 'zod'
function UserForm() {
// Schema created on EVERY render - wasteful
const userSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
age: z.number().int().positive(),
})
const handleSubmit = (data: unknown) => {
const result = userSchema.safeParse(data)
// ...
}
return <form onSubmit={handleSubmit}>...</form>
}Correct (module-level schema):
import { z } from 'zod'
// Schema created ONCE at module load
const userSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
age: z.number().int().positive(),
})
type User = z.infer<typeof userSchema>
function UserForm() {
const handleSubmit = (data: unknown) => {
const result = userSchema.safeParse(data)
// ...
}
return <form onSubmit={handleSubmit}>...</form>
}For dynamic schemas, use useMemo:
import { z } from 'zod'
import { useMemo } from 'react'
function DynamicForm({ minAge }: { minAge: number }) {
// Schema only recreated when minAge changes
const userSchema = useMemo(() =>
z.object({
name: z.string().min(1),
age: z.number().min(minAge),
}),
[minAge]
)
// ...
}For server-side, use module cache:
// schemas/user.ts - created once per process
import { z } from 'zod'
export const userSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
})
// api/users.ts
import { userSchema } from '@/schemas/user'
export async function POST(req: Request) {
const body = await req.json()
const result = userSchema.safeParse(body) // Reuses cached schema
// ...
}Avoid schema factories in hot paths:
// BAD: Factory called on every validation
function createUserSchema(role: string) {
return z.object({
name: z.string(),
permissions: z.array(z.string()),
})
}
// Called in hot loop
users.forEach(user => {
createUserSchema(user.role).parse(user) // New schema every iteration!
})
// GOOD: Cache by key
const schemaCache = new Map<string, z.ZodObject<any>>()
function getUserSchema(role: string) {
if (!schemaCache.has(role)) {
schemaCache.set(role, z.object({
name: z.string(),
permissions: z.array(z.string()),
}))
}
return schemaCache.get(role)!
}
// Reuses cached schemas
users.forEach(user => {
getUserSchema(user.role).parse(user)
})When NOT to use this pattern:
- One-off validation where schema is used once
- Test files where performance doesn't matter
Reference: Zod Performance
Lazy Load Large Schemas
For applications with many complex schemas, importing all of them upfront increases initial bundle size and startup time. Use dynamic imports to lazy load schemas that aren't needed immediately.
Incorrect (importing all schemas upfront):
// schemas/index.ts - barrel file with everything
export * from './user'
export * from './order'
export * from './product'
export * from './analytics' // Large, complex schema
export * from './reports' // Another large schema
export * from './admin' // Admin-only schemas
// app/page.tsx
import { userSchema, orderSchema, analyticsSchema, reportsSchema } from '@/schemas'
// All schemas loaded even if not used on this pageCorrect (lazy loading schemas):
// Only import what's immediately needed
import { userSchema } from '@/schemas/user'
async function loadAnalyticsSchema() {
const { analyticsSchema } = await import('@/schemas/analytics')
return analyticsSchema
}
// Use when needed
async function handleAnalyticsData(data: unknown) {
const schema = await loadAnalyticsSchema()
return schema.safeParse(data)
}Route-based schema loading:
// app/admin/reports/page.tsx
'use client'
import { useEffect, useState } from 'react'
import type { z } from 'zod'
export default function ReportsPage() {
const [schema, setSchema] = useState<z.ZodType | null>(null)
useEffect(() => {
// Load schema only when this route is accessed
import('@/schemas/reports').then(({ reportsSchema }) => {
setSchema(reportsSchema)
})
}, [])
if (!schema) return <Loading />
// Use schema...
}Better pattern with React Suspense:
// schemas/reports.ts
import { z } from 'zod'
export const reportsSchema = z.object({
// Large complex schema
})
// app/admin/reports/page.tsx
import { lazy, Suspense } from 'react'
const ReportsForm = lazy(() => import('./ReportsForm'))
export default function ReportsPage() {
return (
<Suspense fallback={<Loading />}>
<ReportsForm />
</Suspense>
)
}
// ReportsForm.tsx - schema imported with component
import { reportsSchema } from '@/schemas/reports'
export default function ReportsForm() {
// Schema available when component loads
}Schema registry for conditional loading:
// schemas/registry.ts
const schemaLoaders = {
user: () => import('./user').then(m => m.userSchema),
order: () => import('./order').then(m => m.orderSchema),
analytics: () => import('./analytics').then(m => m.analyticsSchema),
reports: () => import('./reports').then(m => m.reportsSchema),
} as const
type SchemaName = keyof typeof schemaLoaders
const schemaCache = new Map<SchemaName, z.ZodType>()
export async function getSchema(name: SchemaName) {
if (!schemaCache.has(name)) {
const schema = await schemaLoaders[name]()
schemaCache.set(name, schema)
}
return schemaCache.get(name)!
}
// Usage
const schema = await getSchema('analytics')
schema.parse(data)When NOT to use this pattern:
- Server-side rendering where all code is available
- Small applications with few schemas
- Schemas used on every page (defeats purpose)
Reference: Next.js Dynamic Imports
Use Zod Mini for Bundle-Sensitive Applications
For frontend applications where bundle size is critical, use @zod/mini instead of zod. Zod Mini provides the same validation capabilities with a functional API that tree-shakes better, reducing bundle size by ~85%.
When to consider Zod Mini:
// Your app if:
// - Bundle size is critical (mobile-first, slow networks)
// - Edge functions with size limits
// - Simple validation needs (no complex transforms)
// - Tree-shaking is important
// Zod: ~17kb gzipped
import { z } from 'zod'
// Zod Mini: ~1.9kb gzipped (when tree-shaken)
import * as z from '@zod/mini'Standard Zod (method chaining):
import { z } from 'zod'
// Methods are attached to schema objects - hard to tree-shake
const userSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
age: z.number().int().positive(),
})
const result = userSchema.safeParse(data)Zod Mini (functional API):
import * as z from '@zod/mini'
// Functions are imported individually - tree-shakeable
const userSchema = z.object({
name: z.pipe(z.string(), z.minLength(1), z.maxLength(100)),
email: z.pipe(z.string(), z.email()),
age: z.pipe(z.number(), z.int(), z.positive()),
})
const result = z.safeParse(userSchema, data)API differences:
// Standard Zod
z.string().min(5).max(100).email()
z.number().int().positive()
z.array(z.string()).min(1)
schema.parse(data)
schema.safeParse(data)
// Zod Mini
z.pipe(z.string(), z.minLength(5), z.maxLength(100), z.email())
z.pipe(z.number(), z.int(), z.positive())
z.pipe(z.array(z.string()), z.minLength(1))
z.parse(schema, data)
z.safeParse(schema, data)When to stick with regular Zod:
// Use regular Zod when:
// - Server-side where bundle size doesn't matter
// - Complex schemas with many transforms
// - Need full method chaining ergonomics
// - Bundle size isn't a constraint
// The 17kb isn't huge - only optimize if needed
// Server: 17kb is negligible
// Browser: 17kb ≈ 0.6ms additional startup on 3GShared schemas between packages:
// shared-schemas/package.json
{
"dependencies": {
"@zod/mini": "^4.0.0" // Mini for frontend-shared schemas
}
}
// If you need both, Zod Mini schemas work with regular Zod
// But prefer consistency - pick one for your codebaseBundle size comparison:
| Package | Gzipped Size | Use Case |
|---|---|---|
zod@3 | ~13kb | Legacy, stable |
zod@4 | ~17kb | Full features |
@zod/mini | ~1.9kb | Bundle-critical |
When NOT to use this pattern:
- Server-side applications (bundle size irrelevant)
- When method chaining ergonomics are preferred
- Complex schemas that benefit from full API
Reference: Zod Mini
Add Path to Refinement Errors
When using .refine() on object schemas for cross-field validation, add a path option to indicate which field the error relates to. Without it, the error appears at the object level, making form error display confusing.
Incorrect (error at object level):
import { z } from 'zod'
const formSchema = z.object({
password: z.string().min(8),
confirmPassword: z.string(),
}).refine(
(data) => data.password === data.confirmPassword,
{ message: 'Passwords do not match' } // No path specified
)
const result = formSchema.safeParse({
password: 'secret123',
confirmPassword: 'different',
})
if (!result.success) {
const flattened = result.error.flatten()
// {
// formErrors: ['Passwords do not match'], // At form level!
// fieldErrors: {} // Empty - no field association
// }
}
// Form UI can't highlight which field has the errorCorrect (error with path):
import { z } from 'zod'
const formSchema = z.object({
password: z.string().min(8),
confirmPassword: z.string(),
}).refine(
(data) => data.password === data.confirmPassword,
{
message: 'Passwords do not match',
path: ['confirmPassword'], // Error appears on this field
}
)
const result = formSchema.safeParse({
password: 'secret123',
confirmPassword: 'different',
})
if (!result.success) {
const flattened = result.error.flatten()
// {
// formErrors: [],
// fieldErrors: {
// confirmPassword: ['Passwords do not match'] // Associated with field
// }
// }
}
// Form can now show error next to confirmPassword inputMultiple cross-field validations:
const dateRangeSchema = z.object({
startDate: z.coerce.date(),
endDate: z.coerce.date(),
minDays: z.number().optional(),
maxDays: z.number().optional(),
}).refine(
(data) => data.endDate >= data.startDate,
{ message: 'End date must be after start date', path: ['endDate'] }
).refine(
(data) => {
if (!data.minDays) return true
const days = (data.endDate.getTime() - data.startDate.getTime()) / 86400000
return days >= data.minDays
},
{ message: 'Date range is too short', path: ['endDate'] }
).refine(
(data) => {
if (!data.maxDays) return true
const days = (data.endDate.getTime() - data.startDate.getTime()) / 86400000
return days <= data.maxDays
},
{ message: 'Date range is too long', path: ['endDate'] }
)With superRefine for multiple path errors:
const orderSchema = z.object({
billingAddress: z.object({
street: z.string(),
city: z.string(),
}),
shippingAddress: z.object({
street: z.string(),
city: z.string(),
}),
sameAsBilling: z.boolean(),
}).superRefine((data, ctx) => {
if (data.sameAsBilling) {
// If sameAsBilling but addresses differ, show errors on shipping
if (data.shippingAddress.street !== data.billingAddress.street) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Must match billing address',
path: ['shippingAddress', 'street'], // Nested path
})
}
if (data.shippingAddress.city !== data.billingAddress.city) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Must match billing address',
path: ['shippingAddress', 'city'],
})
}
}
})When NOT to use this pattern:
- When the error genuinely applies to the whole object
- Simple single-field refinements (path is implicit)
Reference: Zod API - refine
Use catch() for Fault-Tolerant Parsing
When parsing data that might have some invalid fields but you want to accept what's valid, use .catch() to provide fallback values instead of failing entirely. This enables graceful degradation for partially corrupted data.
Incorrect (all-or-nothing parsing):
import { z } from 'zod'
const userPrefsSchema = z.object({
theme: z.enum(['light', 'dark']),
fontSize: z.number().min(8).max(32),
language: z.string(),
notifications: z.boolean(),
})
// Corrupted localStorage data
const stored = {
theme: 'invalid-theme', // Bad
fontSize: 200, // Bad
language: 'en', // Good
notifications: 'yes', // Bad - should be boolean
}
userPrefsSchema.parse(stored)
// ZodError: Invalid enum value at "theme"
// User loses ALL their preferences because one field is badCorrect (fault-tolerant with catch):
import { z } from 'zod'
const userPrefsSchema = z.object({
theme: z.enum(['light', 'dark']).catch('light'),
fontSize: z.number().min(8).max(32).catch(16),
language: z.string().catch('en'),
notifications: z.boolean().catch(true),
})
// Corrupted data
const stored = {
theme: 'invalid-theme',
fontSize: 200,
language: 'en',
notifications: 'yes',
}
const prefs = userPrefsSchema.parse(stored)
// {
// theme: 'light', // Fallback used
// fontSize: 16, // Fallback used
// language: 'en', // Original value preserved
// notifications: true // Fallback used
// }
// User gets mostly working preferences instead of errorCatch with factory function:
// Factory function receives the caught error
const schema = z.object({
data: z.array(z.number()).catch((ctx) => {
console.warn('Invalid data array:', ctx.error)
return [] // Return empty array as fallback
}),
})Use case: API response resilience:
const productSchema = z.object({
id: z.string(),
name: z.string(),
price: z.number().positive(),
// Legacy field that might be missing or wrong format
legacyCode: z.string().catch('UNKNOWN'),
// External data that might be malformed
metadata: z.record(z.string()).catch({}),
})
// API returns partial data
const apiResponse = {
id: 'prod-123',
name: 'Widget',
price: 29.99,
legacyCode: null, // Bad - should be string
metadata: 'invalid', // Bad - should be object
}
const product = productSchema.parse(apiResponse)
// Works! Returns product with fallbacks for bad fieldsDifference between catch() and default():
// .default() - only fills in undefined
z.string().default('fallback')
// undefined -> 'fallback'
// null -> ZodError
// '' -> '' (empty string is valid)
// .catch() - fallback for ANY parse failure
z.string().catch('fallback')
// undefined -> 'fallback'
// null -> 'fallback'
// 123 -> 'fallback'
// Even valid strings pass through unchangedCombining catch with validation:
// Catch only specific validation failures
const schema = z.string()
.email()
.catch('invalid@example.com') // Fallback if not valid email
// Chain for complex defaults
const ageSchema = z.coerce.number()
.int()
.min(0)
.max(120)
.catch(0) // Invalid ages become 0When NOT to use this pattern:
- When invalid data should cause errors (strict validation)
- When you need to know which fields failed (use safeParse)
- Critical fields that must be valid
Reference: Zod API - catch
Use default() for Optional Fields with Defaults
When a field is optional but should have a default value when missing, use .default() instead of handling defaults in business logic. This keeps default values centralized in the schema and ensures consistent behavior.
Incorrect (defaults spread across codebase):
import { z } from 'zod'
const configSchema = z.object({
timeout: z.number().optional(),
retries: z.number().optional(),
debug: z.boolean().optional(),
})
type Config = z.infer<typeof configSchema>
function createClient(config: Config) {
// Defaults handled in business logic - duplicated everywhere
const timeout = config.timeout ?? 5000
const retries = config.retries ?? 3
const debug = config.debug ?? false
// ...
}
function createOtherClient(config: Config) {
// Same defaults duplicated - risk of inconsistency
const timeout = config.timeout ?? 5000
const retries = config.retries ?? 3 // What if someone uses 2 here?
const debug = config.debug ?? false
// ...
}Correct (defaults in schema):
import { z } from 'zod'
const configSchema = z.object({
timeout: z.number().default(5000),
retries: z.number().default(3),
debug: z.boolean().default(false),
})
type Config = z.infer<typeof configSchema>
// { timeout: number; retries: number; debug: boolean }
// No optional - defaults fill in missing values
function createClient(config: Config) {
// config.timeout is guaranteed to exist
console.log(config.timeout) // 5000 if not provided
console.log(config.retries) // 3 if not provided
console.log(config.debug) // false if not provided
}
// Parse fills in defaults
configSchema.parse({})
// { timeout: 5000, retries: 3, debug: false }
configSchema.parse({ timeout: 10000 })
// { timeout: 10000, retries: 3, debug: false }Input type vs Output type with defaults:
const schema = z.object({
name: z.string(),
role: z.enum(['admin', 'user']).default('user'),
})
type SchemaInput = z.input<typeof schema>
// { name: string; role?: 'admin' | 'user' }
type SchemaOutput = z.output<typeof schema>
// { name: string; role: 'admin' | 'user' }
// Input type is optional, output type is requiredDefault with factory function:
// Static default
const schema1 = z.object({
id: z.string().default('temp-id'),
})
// Factory function for dynamic defaults
const schema2 = z.object({
id: z.string().default(() => crypto.randomUUID()),
createdAt: z.date().default(() => new Date()),
})
// Each parse creates new values
schema2.parse({}) // { id: 'abc-123...', createdAt: 2024-01-15... }
schema2.parse({}) // { id: 'def-456...', createdAt: 2024-01-15... }Combining with optional/nullable:
// .optional().default() - if undefined, use default
z.string().optional().default('fallback')
// .nullable().default() - null stays null, only undefined gets default
z.string().nullable().default('fallback')
// null -> null
// undefined -> 'fallback'
// .nullish().default() - both null and undefined get default
z.string().nullish().default('fallback')
// null -> 'fallback'
// undefined -> 'fallback'When NOT to use this pattern:
- When absence of value has different meaning than default
- When defaults depend on other fields (use transform)
Reference: Zod API - default
Use Primitive Schemas Correctly
Zod provides specific schemas for each primitive type. Using the wrong schema (e.g., z.string() when you need z.number()) or falling back to z.any() defeats the purpose of validation entirely, allowing corrupt data through.
Incorrect (wrong primitive or any):
import { z } from 'zod'
// Using any loses all type safety
const userSchema = z.object({
id: z.any(), // Accepts anything - no validation
age: z.string(), // Wrong type - age should be number
active: z.any(), // Should be boolean
})
// This passes validation but data is wrong
userSchema.parse({ id: null, age: "twenty", active: "yes" })
// Result: { id: null, age: "twenty", active: "yes" }Correct (specific primitives):
import { z } from 'zod'
const userSchema = z.object({
id: z.string().uuid(), // Specific format validation
age: z.number().int().positive(), // Correct type with constraints
active: z.boolean(), // Exact boolean type
})
// Now invalid data is rejected
userSchema.parse({ id: null, age: "twenty", active: "yes" })
// Throws ZodError with specific field errorsAvailable primitive schemas:
z.string()- strings with optional regex, min, max, email, url, uuidz.number()- numbers with optional int, positive, negative, min, maxz.bigint()- BigInt valuesz.boolean()- true/false onlyz.date()- Date objectsz.symbol()- Symbol typez.undefined()- undefined onlyz.null()- null onlyz.void()- undefined (for function returns)z.never()- no valid value
When NOT to use this pattern:
- When you genuinely need to accept any value (rare - consider
z.unknown()instead) - When migrating legacy code incrementally (use
z.any()temporarily, then fix)
Reference: Zod Primitives
Related skills
Forks & variants (1)
Zod has 1 known copy in the catalog totaling 250 installs. They canonicalize to this original listing.
- pedronauck - 250 installs
How it compares
zod implements its own SKILL.md workflow rather than a generic substitute skill.
FAQ
Who is zod for?
Agents and developers following the zod SKILL.md guidance.
When should I use zod?
When user intent matches description triggers and quick start scenarios.
Is zod safe to install?
Review the Security Audits panel before production shell or network use.