
Zod
- 520 installs
- 202 repo stars
- Updated August 4, 2026
- secondsky/claude-skills
zod is a Claude Code skill that teaches TypeScript-first schema validation and type inference with Zod 4.x for API payloads, forms, environment variables, and runtime-safe parsing.
About
zod is a Claude Code skill from secondsky/claude-skills—part of a 170-skill collection and the tooling-skills plugin suite—that documents Zod 4.x schema validation for TypeScript projects. The skill covers z.object, z.string, z.union, z.discriminatedUnion, refinements, transforms, safeParse error handling, and JSON Schema export for OpenAPI or AI tool contracts. It targets Zod package version 4.1.12 or newer with a 2kb gzipped zero-dependency core, requiring TypeScript 5.5+ and strict compiler settings. Developers reach for zod when validating API requests and responses, parsing environment variables, building react-hook-form or tRPC stacks, or debugging missing validation and type inference issues. The skill metadata is version 2.0.0 last verified 2025-11-17 and installs via tooling-skills@claude-skills rather than legacy per-skill plugin commands in the v2.0+ marketplace layout.
- zod
Zod by the numbers
- 520 all-time installs (skills.sh)
- +18 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #808 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/secondsky/claude-skills --skill zodAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 520 |
|---|---|
| repo stars | ★ 202 |
| Last updated | August 4, 2026 |
| Repository | secondsky/claude-skills ↗ |
How do you validate API payloads with Zod 4?
Use zod for development tasks
Who is it for?
TypeScript developers adding runtime validation with automatic type inference for APIs, configs, and forms on Zod 4.1.12+.
Skip if: JavaScript-only codebases without TypeScript strict mode, or projects still locked on Zod 3.x APIs without migration capacity.
When should I use this skill?
User needs Zod schemas, safeParse validation, z.infer types, JSON Schema export, or help debugging Zod 4 validation errors.
What you get
Zod schemas, inferred TypeScript types, safeParse handlers, refinements, transforms, and optional JSON Schema exports.
- Zod schema definitions
- Inferred TypeScript types
- JSON Schema or OpenAPI-ready exports
By the numbers
- Documents Zod 4.1.12+ with a 2kb gzipped zero-dependency core
- Part of secondsky/claude-skills 170-skill collection at skill version 2.0.0
Files
Zod: TypeScript-First Schema Validation
Overview
Zod is a TypeScript-first validation library that enables developers to define schemas for validating data at runtime while automatically inferring static TypeScript types. With zero dependencies and a 2kb core bundle (gzipped), Zod provides immutable, composable validation with comprehensive error handling.
Installation
bun add zod
# or
bun add zod
# or
bun add zod
# or
yarn add zodRequirements:
- TypeScript v5.5+ with
"strict": trueintsconfig.json - Zod 4.x (4.1.12+)
Important: This skill documents Zod 4.x features. The following APIs require Zod 4 and are NOT available in Zod 3.x:
z.codec()- Bidirectional transformationsz.iso.date(),z.iso.time(),z.iso.datetime(),z.iso.duration()- ISO format validatorsz.toJSONSchema()- JSON Schema generationz.treeifyError(),z.prettifyError(),z.flattenError()- New error formatting helpers.meta()- Enhanced metadata (Zod 3.x only has.describe())- Unified
errorparameter - Replacesmessage,invalid_type_error,required_error,errorMap
For Zod 3.x compatibility or migration guidance, see https://zod.dev
Migrating from Zod v3 to v4
Load `references/migration-guide.md` for complete v3 to v4 migration documentation.
Quick Summary
Zod v4 introduces breaking changes for better performance:
- Error customization: Use unified
errorparameter (replacesmessage,invalid_type_error,required_error) - Number validation: Stricter - rejects
Infinityand unsafe integers - String formats: Now top-level functions (
z.email()vsz.string().email()) - Object defaults: Applied even in optional fields
- Deprecated APIs: Use
.extend()(not.merge()),z.treeifyError()(noterror.format()) - Function validation: Use
.implement()method - UUID validation: Stricter RFC 9562/4122 compliance
→ Load `references/migration-guide.md` for: Complete breaking changes, migration checklist, gradual migration strategy, rollback instructions
Core Concepts
Basic Usage Pattern
import { z } from "zod";
// Define schema
const UserSchema = z.object({
username: z.string(),
age: z.number().int().positive(),
email: z.string().email(),
});
// Infer TypeScript type
type User = z.infer<typeof UserSchema>;
// Validate data (throws on error)
const user = UserSchema.parse(data);
// Validate data (returns result object)
const result = UserSchema.safeParse(data);
if (result.success) {
console.log(result.data); // Typed!
} else {
console.error(result.error); // ZodError
}Parsing Methods
Use the appropriate parsing method based on error handling needs:
- `.parse(data)` - Throws
ZodErroron invalid input; returns strongly-typed data on success - `.safeParse(data)` - Returns
{ success: true, data }or{ success: false, error }(no exceptions) - `.parseAsync(data)` - For schemas with async refinements/transforms
- `.safeParseAsync(data)` - Async version that doesn't throw
Best Practice: Use .safeParse() to avoid try-catch blocks and leverage discriminated unions.
Primitive Types
Strings
z.string() // Basic string
z.string().min(5) // Minimum length
z.string().max(100) // Maximum length
z.string().length(10) // Exact length
z.string().email() // Email validation
z.string().url() // URL validation
z.string().uuid() // UUID format
z.string().regex(/^\d+$/) // Custom pattern
z.string().startsWith("pre") // Prefix check
z.string().endsWith("suf") // Suffix check
z.string().trim() // Auto-trim whitespace
z.string().toLowerCase() // Auto-lowercase
z.string().toUpperCase() // Auto-uppercase
// ISO formats (Zod 4+)
z.iso.date() // YYYY-MM-DD
z.iso.time() // HH:MM:SS
z.iso.datetime() // ISO 8601 datetime
z.iso.duration() // ISO 8601 duration
// Network formats
z.ipv4() // IPv4 address
z.ipv6() // IPv6 address
z.cidrv4() // IPv4 CIDR notation
z.cidrv6() // IPv6 CIDR notation
// Other formats
z.jwt() // JWT token
z.nanoid() // Nanoid
z.cuid() // CUID
z.cuid2() // CUID2
z.ulid() // ULID
z.base64() // Base64 encoded
z.hex() // HexadecimalNumbers
z.number() // Basic number
z.number().int() // Integer only
z.number().positive() // > 0
z.number().nonnegative() // >= 0
z.number().negative() // < 0
z.number().nonpositive() // <= 0
z.number().min(0) // Minimum value
z.number().max(100) // Maximum value
z.number().gt(0) // Greater than
z.number().gte(0) // Greater than or equal
z.number().lt(100) // Less than
z.number().lte(100) // Less than or equal
z.number().multipleOf(5) // Must be multiple of 5
z.int() // Shorthand for z.number().int()
z.int32() // 32-bit integer
z.nan() // NaN valueCoercion (Type Conversion)
z.coerce.string() // Convert to string
z.coerce.number() // Convert to number
z.coerce.boolean() // Convert to boolean
z.coerce.bigint() // Convert to bigint
z.coerce.date() // Convert to Date
// Example: Parse query parameters
const QuerySchema = z.object({
page: z.coerce.number().int().positive(),
limit: z.coerce.number().int().max(100).default(10),
});
// "?page=5&limit=20" -> { page: 5, limit: 20 }Other Primitives
z.boolean() // Boolean
z.date() // Date object
z.date().min(new Date("2020-01-01"))
z.date().max(new Date("2030-12-31"))
z.bigint() // BigInt
z.symbol() // Symbol
z.null() // Null
z.undefined() // Undefined
z.void() // Void (undefined)Complex Types
Objects
const PersonSchema = z.object({
name: z.string(),
age: z.number(),
address: z.object({
street: z.string(),
city: z.string(),
country: z.string(),
}),
});
type Person = z.infer<typeof PersonSchema>;
// Object methods
PersonSchema.shape // Access shape
PersonSchema.keyof() // Get union of keys
PersonSchema.extend({ role: z.string() }) // Add fields
PersonSchema.pick({ name: true }) // Pick specific fields
PersonSchema.omit({ age: true }) // Omit fields
PersonSchema.partial() // Make all fields optional
PersonSchema.required() // Make all fields required
PersonSchema.deepPartial() // Recursively optional
// Strict vs loose objects
z.strictObject({ ... }) // No extra keys allowed (throws)
z.object({ ... }) // Strips extra keys (default)
z.looseObject({ ... }) // Allows extra keysArrays
z.array(z.string()) // String array
z.array(z.number()).min(1) // At least 1 element
z.array(z.number()).max(10) // At most 10 elements
z.array(z.number()).length(5) // Exactly 5 elements
z.array(z.number()).nonempty() // At least 1 element
// Nested arrays
z.array(z.array(z.number())) // number[][]Tuples
z.tuple([z.string(), z.number()]) // [string, number]
z.tuple([z.string(), z.number()]).rest(z.boolean()) // [string, number, ...boolean[]]Enums and Literals
// Enum
const RoleEnum = z.enum(["admin", "user", "guest"]);
type Role = z.infer<typeof RoleEnum>; // "admin" | "user" | "guest"
// Literal values
z.literal("exact_value")
z.literal(42)
z.literal(true)
// Native TypeScript enum
enum Fruits {
Apple,
Banana,
}
z.nativeEnum(Fruits)
// Enum methods
RoleEnum.enum.admin // "admin"
RoleEnum.exclude(["guest"]) // Exclude values
RoleEnum.extract(["admin", "user"]) // Include onlyUnions
// Basic union
z.union([z.string(), z.number()])
// Discriminated union (better performance & type inference)
const ResponseSchema = z.discriminatedUnion("status", [
z.object({ status: z.literal("success"), data: z.any() }),
z.object({ status: z.literal("error"), message: z.string() }),
]);
type Response = z.infer<typeof ResponseSchema>;
// { status: "success", data: any } | { status: "error", message: string }Intersections
const BaseSchema = z.object({ id: z.string() });
const ExtendedSchema = z.object({ name: z.string() });
const Combined = z.intersection(BaseSchema, ExtendedSchema);
// Equivalent to: z.object({ id: z.string(), name: z.string() })Records and Maps
// Record: object with typed keys and values
z.record(z.string()) // { [key: string]: string }
z.record(z.string(), z.number()) // { [key: string]: number }
// Partial record (some keys optional)
z.partialRecord(z.enum(["a", "b"]), z.string())
// Map
z.map(z.string(), z.number()) // Map<string, number>
z.set(z.string()) // Set<string>Advanced Patterns
Load `references/advanced-patterns.md` for complete advanced validation and transformation patterns.
Quick Reference
Refinements (custom validation):
z.string().refine((val) => val.length >= 8, "Too short");
z.object({ password, confirmPassword }).superRefine((data, ctx) => { /* ... */ });Transformations (modify data):
z.string().transform((val) => val.trim());
z.string().pipe(z.coerce.number());Codecs (bidirectional transforms - NEW in v4.1):
const DateCodec = z.codec(
z.iso.datetime(),
z.date(),
{
decode: (str) => new Date(str),
encode: (date) => date.toISOString(),
}
);Recursive Types:
const CategorySchema: z.ZodType<Category> = z.lazy(() =>
z.object({ name: z.string(), subcategories: z.array(CategorySchema) })
);Optional/Nullable:
z.string().optional() // string | undefined
z.string().nullable() // string | null
z.string().default("default") // Provides default if undefinedReadonly & Brand:
z.object({ ... }).readonly() // Readonly properties
z.string().brand<"UserId">() // Nominal typing→ Load `references/advanced-patterns.md` for: Complete refinement patterns, async validation, codec examples, composable schemas, conditional validation, performance optimization
Error Handling
Load `references/error-handling.md` for complete error formatting and customization guide.
Quick Reference
Error Formatting Methods:
// For forms
const { fieldErrors } = z.flattenError(error);
// For nested data
const tree = z.treeifyError(error);
const nameError = tree.properties?.user?.properties?.name?.errors?.[0];
// For debugging
console.log(z.prettifyError(error));Custom Error Messages (three levels):
// 1. Schema-level (highest priority)
z.string({ error: "Custom message" });
z.string().min(5, "Too short");
// 2. Per-parse level
schema.parse(data, { error: (issue) => ({ message: "..." }) });
// 3. Global level
z.config({ customError: (issue) => ({ message: "..." }) });Localization (40+ languages):
z.config(z.locales.es()); // Spanish
z.config(z.locales.fr()); // French→ Load `references/error-handling.md` for: Complete error formatting examples, custom error patterns, localization setup, error code reference
Type Inference
Load `references/type-inference.md` for complete type inference and metadata documentation.
Quick Reference
Basic Type Inference:
const UserSchema = z.object({ name: z.string() });
type User = z.infer<typeof UserSchema>; // { name: string }Input vs Output (for transforms):
const TransformSchema = z.string().transform((s) => s.length);
type Input = z.input<typeof TransformSchema>; // string
type Output = z.output<typeof TransformSchema>; // numberJSON Schema Conversion:
const jsonSchema = z.toJSONSchema(UserSchema, {
target: "openapi-3.0",
metadata: true,
});Metadata:
// Add metadata
const EmailSchema = z.string().email().meta({
title: "Email Address",
description: "User's email address",
});
// Create custom registry
const formRegistry = z.registry<FormFieldMeta>();→ Load `references/type-inference.md` for: Complete type inference patterns, JSON Schema options, metadata system, custom registries, brand types
Functions
Validate function inputs and outputs:
const AddFunction = z.function()
.args(z.number(), z.number()) // Arguments
.returns(z.number()); // Return type
// Implement typed function
const add = AddFunction.implement((a, b) => {
return a + b; // Type-checked!
});
// Async functions
const FetchFunction = z.function()
.args(z.string())
.returns(z.promise(z.object({ data: z.any() })))
.implementAsync(async (url) => {
const response = await fetch(url);
return response.json();
});Common Patterns
Environment Variables
const EnvSchema = z.object({
NODE_ENV: z.enum(["development", "production", "test"]),
DATABASE_URL: z.string().url(),
PORT: z.coerce.number().int().positive().default(3000),
API_KEY: z.string().min(32),
});
// Validate on startup
const env = EnvSchema.parse(process.env);
// Now use typed env
console.log(env.PORT); // numberAPI Request Validation
const CreateUserRequest = z.object({
username: z.string().min(3).max(20),
email: z.string().email(),
password: z.string().min(8),
age: z.number().int().positive().optional(),
});
// Express example
app.post("/users", async (req, res) => {
const result = CreateUserRequest.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
errors: z.flattenError(result.error).fieldErrors,
});
}
const user = await createUser(result.data);
res.json(user);
});Form Validation
const FormSchema = z.object({
firstName: z.string().min(1, "First name required"),
lastName: z.string().min(1, "Last name required"),
email: z.string().email("Invalid email"),
age: z.coerce.number().int().min(18, "Must be 18+"),
agreeToTerms: z.literal(true, {
errorMap: () => ({ message: "Must accept terms" }),
}),
});
type FormData = z.infer<typeof FormSchema>;Partial Updates
const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
});
// For PATCH requests: make everything optional except id
const UpdateUserSchema = UserSchema.partial().required({ id: true });
type UpdateUser = z.infer<typeof UpdateUserSchema>;
// { id: string; name?: string; email?: string }Composable Schemas
// Base schemas
const TimestampSchema = z.object({
createdAt: z.date(),
updatedAt: z.date(),
});
const AuthorSchema = z.object({
authorId: z.string(),
authorName: z.string(),
});
// Compose into larger schemas
const PostSchema = z.object({
id: z.string(),
title: z.string(),
content: z.string(),
}).merge(TimestampSchema).merge(AuthorSchema);Ecosystem Integration
Load `references/ecosystem-integrations.md` for complete framework and tooling integration guide.
Quick Reference
ESLint Plugins:
eslint-plugin-zod-x- Enforces best practiceseslint-plugin-import-zod- Enforces import style
Framework Integrations:
- tRPC - End-to-end typesafe APIs
- React Hook Form - Form validation (see
react-hook-form-zodskill) - Prisma - Generate Zod from database models
- NestJS - DTOs and validation pipes
Code Generation:
- orval - OpenAPI → Zod
- Hey API - OpenAPI to TypeScript + Zod
- kubb - API toolkit with codegen
→ Load `references/ecosystem-integrations.md` for: Setup instructions, integration examples, Hono middleware, Drizzle ORM patterns
Troubleshooting
Load `references/troubleshooting.md` for complete troubleshooting guide, performance tips, and best practices.
Quick Reference
Common Issues: 1. TypeScript strict mode required → Enable in tsconfig.json 2. Large bundle size → Use z.lazy() for code splitting 3. Slow async refinements → Cache or debounce 4. Circular dependencies → Use z.lazy() 5. Slow unions → Use z.discriminatedUnion() 6. Transform vs refine confusion → Use .refine() for validation, .transform() for modification
Performance Tips:
- Use
.discriminatedUnion()(5-10x faster than.union()) - Cache schema instances
- Use
.safeParse()(avoids try-catch overhead) - Lazy load large schemas
Best Practices:
- Define schemas at module level
- Use type inference (
z.infer) - Add custom error messages
- Validate at system boundaries
- Compose small schemas
- Document with
.meta()
→ Load `references/troubleshooting.md` for: Detailed solutions, performance optimization, best practices, testing patterns
Quick Reference
// Primitives
z.string(), z.number(), z.boolean(), z.date(), z.bigint()
// Collections
z.array(), z.tuple(), z.object(), z.record(), z.map(), z.set()
// Special types
z.enum(), z.union(), z.discriminatedUnion(), z.intersection()
z.literal(), z.any(), z.unknown(), z.never()
// Modifiers
.optional(), .nullable(), .nullish(), .default(), .catch()
.readonly(), .brand()
// Validation
.min(), .max(), .length(), .regex(), .email(), .url(), .uuid()
.refine(), .superRefine()
// Transformation
.transform(), .pipe(), .codec()
// Parsing
.parse(), .safeParse(), .parseAsync(), .safeParseAsync()
// Type inference
z.infer<typeof Schema>, z.input<typeof Schema>, z.output<typeof Schema>
// Error handling
z.flattenError(), z.treeifyError(), z.prettifyError()
// JSON Schema
z.toJSONSchema(schema, options)
// Metadata
.meta(), .describe()
// Object methods
.extend(), .pick(), .omit(), .partial(), .required(), .merge()When to Load References
Load `references/migration-guide.md` when:
- Upgrading from Zod v3 to v4
- Questions about breaking changes
- Need migration checklist or rollback strategy
- Errors related to deprecated APIs (
.merge(),error.format(), etc.) - Number validation issues with
Infinityor unsafe integers
Load `references/error-handling.md` when:
- Need to format errors for forms or UI
- Implementing custom error messages
- Questions about
z.flattenError(),z.treeifyError(), orz.prettifyError() - Setting up localization for error messages
- Need error code reference or pattern examples
Load `references/advanced-patterns.md` when:
- Implementing custom refinements or async validation
- Need bidirectional transformations (codecs)
- Working with recursive types or self-referential data
- Questions about
.refine(),.transform(), or.codec() - Need performance optimization patterns
- Implementing conditional validation
Load `references/type-inference.md` when:
- Questions about TypeScript type inference
- Need to generate JSON Schema for OpenAPI or AI
- Implementing metadata system for forms or documentation
- Need custom registries for type-safe metadata
- Questions about
z.infer,z.input,z.output - Using brand types for ID safety
Load `references/ecosystem-integrations.md` when:
- Integrating with tRPC, React Hook Form, Prisma, or NestJS
- Setting up ESLint plugins for best practices
- Generating Zod schemas from OpenAPI (orval, Hey API, kubb)
- Questions about Hono middleware or Drizzle ORM
- Need framework-specific integration examples
Load `references/troubleshooting.md` when:
- Encountering TypeScript strict mode errors
- Bundle size concerns or lazy loading needs
- Performance issues with large unions or async refinements
- Questions about circular dependencies
- Need best practices or testing patterns
- Confusion between
.refine()and.transform()
Additional Resources
- Official Docs: https://zod.dev
- GitHub: https://github.com/colinhacks/zod
- TypeScript Playground: https://zod-playground.vercel.app
- ESLint Plugin (Best Practices): https://github.com/JoshuaKGoldberg/eslint-plugin-zod-x
- tRPC Integration: https://trpc.io
- Ecosystem: https://zod.dev/ecosystem
---
Production Notes:
- Package version: 4.1.12+ (Zod 4.x stable)
- Zero dependencies
- Bundle size: 2kb (gzipped)
- TypeScript 5.5+ required
- Strict mode required
- Last verified: 2025-11-17
- Skill version: 2.0.0 (Updated with v4.1 enhancements)
What's New in This Version:
- ✨ Comprehensive v3 to v4 migration guide with breaking changes
- ✨ Enhanced error customization with three-level system
- ✨ Expanded metadata API with registry system
- ✨ Improved error formatting with practical examples
- ✨ Built-in localization support for 40+ locales
- ✨ Detailed codec documentation with real-world patterns
- ✨ Performance improvements and architectural changes explained
Zod Advanced Patterns Guide
Complete guide for advanced Zod validation patterns including refinements, transformations, codecs, and recursive types.
Last Updated: 2025-11-17
---
Refinements (Custom Validation)
Refinements allow you to add custom validation logic beyond Zod's built-in validators.
Basic Refinement
const PasswordSchema = z.string().refine(
(val) => val.length >= 8,
{ message: "Password must be at least 8 characters" }
);Multiple Refinements
const SafePasswordSchema = z.string()
.refine((val) => val.length >= 8, "Too short")
.refine((val) => /[A-Z]/.test(val), "Must contain uppercase")
.refine((val) => /[0-9]/.test(val), "Must contain number");SuperRefine (Multiple Issues at Once)
const UserSchema = z.object({
password: z.string(),
confirmPassword: z.string(),
}).superRefine((data, ctx) => {
if (data.password !== data.confirmPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["confirmPassword"],
message: "Passwords must match",
});
}
});Async Refinement
const UsernameSchema = z.string().refine(
async (username) => {
const exists = await checkUsernameExists(username);
return !exists;
},
{ message: "Username already taken" }
);---
Transformations
Transformations allow you to modify data during parsing.
Basic Transform
// Transform data during parsing
const StringToNumberSchema = z.string().transform((val) => parseInt(val));
const result = StringToNumberSchema.parse("123"); // 123 (number)Chained Transformations
const TrimAndLowercaseSchema = z.string()
.transform((val) => val.trim())
.transform((val) => val.toLowerCase());Pipe (Combine Schemas with Transformation)
const NumberStringSchema = z.string().pipe(z.coerce.number());---
Codecs (Bidirectional Transformations)
New in Zod v4.1: Codecs enable bidirectional transformations between two schemas, perfect for handling data at network boundaries or converting between different representations.
What Are Codecs?
Unlike .transform() which is unidirectional (input → output), codecs define transformations in both directions:
- Forward (decode): Convert from input format to output format
- Backward (encode): Convert from output format back to input format
All Zod schemas support both directions via .decode() and .encode() methods.
Basic Example: Date Codec
// String <-> Date codec
const DateCodec = z.codec(
z.iso.datetime(), // Input schema (ISO string)
z.date(), // Output schema (Date object)
{
decode: (str) => new Date(str), // String → Date
encode: (date) => date.toISOString(), // Date → String
}
);
// Decode: string → Date
const date = DateCodec.decode("2024-01-01T00:00:00Z");
console.log(date instanceof Date); // true
// Encode: Date → string
const isoString = DateCodec.encode(new Date());
console.log(typeof isoString); // "string"Type Safety
Unlike .parse() which accepts unknown, codec methods require strongly-typed inputs:
const DateCodec = z.codec(z.iso.datetime(), z.date(), {
decode: (str) => new Date(str),
encode: (date) => date.toISOString(),
});
// ✓ Type-safe decode (expects string)
DateCodec.decode("2024-01-01T00:00:00Z");
// ✗ Type error: number is not assignable to string
DateCodec.decode(123456789);
// ✓ Type-safe encode (expects Date)
DateCodec.encode(new Date());
// ✗ Type error: string is not assignable to Date
DateCodec.encode("2024-01-01");Safe Variants (No Exceptions)
Codecs provide safe methods that return result objects instead of throwing:
// Safe decode
const decodeResult = DateCodec.decodeSafe("2024-01-01T00:00:00Z");
if (decodeResult.success) {
console.log(decodeResult.data); // Date object
} else {
console.error(decodeResult.error); // ZodError
}
// Safe encode
const encodeResult = DateCodec.encodeSafe(new Date());
if (encodeResult.success) {
console.log(encodeResult.data); // ISO string
} else {
console.error(encodeResult.error); // ZodError
}
// Async safe variants
await DateCodec.decodeAsync(data);
await DateCodec.decodeSafeAsync(data);
await DateCodec.encodeAsync(data);
await DateCodec.encodeSafeAsync(data);Composability
Codecs work seamlessly within objects, arrays, and other schemas:
const EventSchema = z.object({
id: z.string().uuid(),
title: z.string(),
createdAt: DateCodec, // Automatically handles conversion
updatedAt: DateCodec,
metadata: z.record(z.string()),
});
// When parsing API response (JSON with ISO strings)
const event = EventSchema.decode({
id: "550e8400-e29b-41d4-a716-446655440000",
title: "Launch Event",
createdAt: "2024-01-01T00:00:00Z", // String → Date
updatedAt: "2024-01-02T00:00:00Z", // String → Date
metadata: { location: "Online" },
});
console.log(event.createdAt instanceof Date); // true
// When sending to API (Date objects → ISO strings)
const payload = EventSchema.encode({
id: "550e8400-e29b-41d4-a716-446655440000",
title: "Launch Event",
createdAt: new Date("2024-01-01"), // Date → String
updatedAt: new Date("2024-01-02"), // Date → String
metadata: { location: "Online" },
});
console.log(typeof payload.createdAt); // "string"Common Codec Patterns
// 1. JSON String Codec
const JSONCodec = <T extends z.ZodTypeAny>(schema: T) =>
z.codec(
z.string(),
schema,
{
decode: (str) => JSON.parse(str),
encode: (obj) => JSON.stringify(obj),
}
);
const UserJSONCodec = JSONCodec(z.object({
name: z.string(),
age: z.number(),
}));
// 2. Base64 Codec
const Base64Codec = z.codec(
z.string(),
z.instanceof(Uint8Array),
{
decode: (base64) => Uint8Array.from(atob(base64), c => c.charCodeAt(0)),
encode: (bytes) => btoa(String.fromCharCode(...bytes)),
}
);
// 3. URL Search Params Codec
const QueryParamsCodec = <T extends z.ZodTypeAny>(schema: T) =>
z.codec(
z.string(),
schema,
{
decode: (queryString) => {
const params = new URLSearchParams(queryString);
return Object.fromEntries(params.entries());
},
encode: (obj) => new URLSearchParams(obj).toString(),
}
);
// 4. Milliseconds <-> Seconds Codec
const SecondsCodec = z.codec(
z.number().int().nonnegative(), // Input: seconds
z.number().int().nonnegative(), // Output: milliseconds
{
decode: (seconds) => seconds * 1000,
encode: (ms) => Math.floor(ms / 1000),
}
);When to Use Codecs
Use codecs when:
- Parsing data at network boundaries (API requests/responses)
- Converting between storage and runtime formats
- Handling serialization/deserialization (JSON, Base64, etc.)
- Working with timestamps in different units
- Need bidirectional type-safe conversions
Use `.transform()` when:
- One-way transformation is sufficient
- Don't need to convert back to original format
- Simpler use case without encode/decode symmetry
Practical Example: API Client
// Define API schema with codecs
const UserAPISchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
createdAt: z.codec(
z.iso.datetime(),
z.date(),
{
decode: (str) => new Date(str),
encode: (date) => date.toISOString(),
}
),
lastLogin: z.codec(
z.iso.datetime(),
z.date(),
{
decode: (str) => new Date(str),
encode: (date) => date.toISOString(),
}
).nullable(),
});
// Fetch from API (JSON → TypeScript objects)
async function getUser(id: string) {
const response = await fetch(`/api/users/${id}`);
const json = await response.json();
return UserAPISchema.decode(json); // Dates are Date objects
}
// Send to API (TypeScript objects → JSON)
async function updateUser(user: z.output<typeof UserAPISchema>) {
const payload = UserAPISchema.encode(user); // Dates are ISO strings
await fetch(`/api/users/${user.id}`, {
method: "PUT",
body: JSON.stringify(payload),
});
}---
Recursive Types
For self-referential data structures:
interface Category {
name: string;
subcategories: Category[];
}
const CategorySchema: z.ZodType<Category> = z.lazy(() =>
z.object({
name: z.string(),
subcategories: z.array(CategorySchema),
})
);---
Optional, Nullable, and Default Values
z.string().optional() // string | undefined
z.string().nullable() // string | null
z.string().nullish() // string | null | undefined
z.string().default("default") // Provides default if undefined
z.string().catch("fallback") // Provides fallback on error
// Prefault (default before parsing)
z.coerce.number().prefault(0)
// Remove undefined/null (opposite of optional/nullable)
z.string().optional().unwrap() // Back to z.string()---
Readonly
Make all properties readonly:
const ReadonlyUserSchema = z.object({
name: z.string(),
age: z.number(),
}).readonly();
type ReadonlyUser = z.infer<typeof ReadonlyUserSchema>;
// { readonly name: string; readonly age: number }---
Brand (Nominal Typing)
Create distinct types that prevent accidental mixing:
const UserId = z.string().brand<"UserId">();
const ProductId = z.string().brand<"ProductId">();
type UserId = z.infer<typeof UserId>; // string & Brand<"UserId">
type ProductId = z.infer<typeof ProductId>; // string & Brand<"ProductId">
// TypeScript prevents mixing them
function getUser(id: UserId) { /* ... */ }
const userId = UserId.parse("user-123");
const productId = ProductId.parse("prod-456");
getUser(userId); // ✓ OK
getUser(productId); // ✗ Type error!---
Advanced Object Patterns
Partial Updates (PATCH Requests)
const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
});
// Make everything optional except id
const UpdateUserSchema = UserSchema.partial().required({ id: true });
type UpdateUser = z.infer<typeof UpdateUserSchema>;
// { id: string; name?: string; email?: string }Deep Partial (Nested Optional)
const NestedSchema = z.object({
user: z.object({
profile: z.object({
name: z.string(),
age: z.number(),
}),
}),
});
const DeepPartialSchema = NestedSchema.deepPartial();
// All nested fields become optionalPick and Omit
const PersonSchema = z.object({
name: z.string(),
age: z.number(),
email: z.string().email(),
address: z.string(),
});
// Pick specific fields
const NameEmailSchema = PersonSchema.pick({ name: true, email: true });
// Omit fields
const WithoutAddressSchema = PersonSchema.omit({ address: true });---
Composable Schemas
Build complex schemas from reusable pieces:
// Base schemas
const TimestampSchema = z.object({
createdAt: z.date(),
updatedAt: z.date(),
});
const AuthorSchema = z.object({
authorId: z.string(),
authorName: z.string(),
});
// Compose into larger schemas
const PostSchema = z.object({
id: z.string(),
title: z.string(),
content: z.string(),
}).merge(TimestampSchema).merge(AuthorSchema);---
Conditional Validation
Use refinements for conditional logic:
const ConditionalSchema = z.object({
type: z.enum(["individual", "company"]),
firstName: z.string().optional(),
lastName: z.string().optional(),
companyName: z.string().optional(),
}).superRefine((data, ctx) => {
if (data.type === "individual") {
if (!data.firstName || !data.lastName) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "First and last name required for individuals",
});
}
} else {
if (!data.companyName) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Company name required for companies",
});
}
}
});---
Performance Patterns
Lazy Loading Large Schemas
// Don't load schema until needed
const HeavySchema = z.lazy(() => import('./schemas/heavy').then(m => m.schema));Discriminated Unions for Performance
// ✓ Fast - checks discriminator first
const ResponseSchema = z.discriminatedUnion("status", [
z.object({ status: z.literal("success"), data: z.any() }),
z.object({ status: z.literal("error"), message: z.string() }),
]);
// ✗ Slower - tries all branches
const SlowResponseSchema = z.union([
z.object({ status: z.literal("success"), data: z.any() }),
z.object({ status: z.literal("error"), message: z.string() }),
]);---
Best Practices
1. Use refinements for complex validation that can't be expressed with built-in methods 2. Use transforms for data normalization, not validation 3. Use codecs for bidirectional conversions at API boundaries 4. Cache schema instances - don't recreate on every use 5. Use discriminated unions instead of regular unions for better performance 6. Leverage brands to prevent ID mixing bugs 7. Compose small schemas into larger ones for maintainability 8. Use lazy loading for large or rarely-used schemas
---
See also:
error-handling.mdfor handling refinement errorstype-inference.mdfor advanced type inference patterns
/**
* Common Zod Patterns - Production-Ready Examples
*
* This file contains frequently used Zod validation patterns
* for real-world applications.
*
* @requires zod ^4.1.12 (Zod 4.x)
* @note Uses Zod 4 APIs: z.codec, z.iso.datetime, z.flattenError, z.prettifyError
*/
import { z } from "zod";
// ============================================================================
// ENVIRONMENT VARIABLES
// ============================================================================
export const EnvSchema = z.object({
NODE_ENV: z.enum(["development", "production", "test"]),
DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url().optional(),
PORT: z.coerce.number().int().positive().default(3000),
API_KEY: z.string().min(32),
JWT_SECRET: z.string().min(64),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
});
export type Env = z.infer<typeof EnvSchema>;
// Usage: Validate on app startup
// const env = EnvSchema.parse(process.env);
// ============================================================================
// API REQUEST/RESPONSE VALIDATION
// ============================================================================
// Create User Request
export const CreateUserRequest = z.object({
username: z.string().min(3).max(20).regex(/^[a-zA-Z0-9_]+$/),
email: z.string().email(),
password: z.string().min(8).max(100),
firstName: z.string().min(1).max(50).optional(),
lastName: z.string().min(1).max(50).optional(),
age: z.number().int().min(13).max(120).optional(),
});
export type CreateUserRequest = z.infer<typeof CreateUserRequest>;
// Update User Request (partial)
export const UpdateUserRequest = CreateUserRequest.partial().extend({
id: z.string().uuid(),
});
export type UpdateUserRequest = z.infer<typeof UpdateUserRequest>;
// User Response
export const UserResponse = z.object({
id: z.string().uuid(),
username: z.string(),
email: z.string().email(),
firstName: z.string().nullable(),
lastName: z.string().nullable(),
age: z.number().int().positive().nullable(),
createdAt: z.date(),
updatedAt: z.date(),
});
export type UserResponse = z.infer<typeof UserResponse>;
// Paginated Response
export const PaginatedResponse = <T extends z.ZodTypeAny>(itemSchema: T) =>
z.object({
items: z.array(itemSchema),
total: z.number().int().nonnegative(),
page: z.number().int().positive(),
pageSize: z.number().int().positive(),
hasMore: z.boolean(),
});
// Usage: const PaginatedUsers = PaginatedResponse(UserResponse);
// ============================================================================
// FORM VALIDATION
// ============================================================================
export const LoginFormSchema = z.object({
email: z.string().email("Invalid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
rememberMe: z.boolean().default(false),
});
export type LoginFormData = z.infer<typeof LoginFormSchema>;
export const SignupFormSchema = z
.object({
email: z.string().email("Invalid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
confirmPassword: z.string(),
agreeToTerms: z.literal(true, {
errorMap: () => ({ message: "You must accept the terms and conditions" }),
}),
})
.superRefine((data, ctx) => {
if (data.password !== data.confirmPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["confirmPassword"],
message: "Passwords do not match",
});
}
});
export type SignupFormData = z.infer<typeof SignupFormSchema>;
// ============================================================================
// DISCRIMINATED UNIONS
// ============================================================================
export const ApiResponse = z.discriminatedUnion("status", [
z.object({
status: z.literal("success"),
data: z.any(),
}),
z.object({
status: z.literal("error"),
message: z.string(),
code: z.string().optional(),
}),
]);
export type ApiResponse = z.infer<typeof ApiResponse>;
export const NotificationSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("email"),
to: z.string().email(),
subject: z.string(),
body: z.string(),
}),
z.object({
type: z.literal("sms"),
to: z.string().regex(/^\+?[1-9]\d{1,14}$/),
message: z.string().max(160),
}),
z.object({
type: z.literal("push"),
deviceToken: z.string(),
title: z.string(),
body: z.string(),
}),
]);
export type Notification = z.infer<typeof NotificationSchema>;
// ============================================================================
// REFINEMENTS & TRANSFORMATIONS
// ============================================================================
// Strong password validation
export const StrongPasswordSchema = z
.string()
.min(8, "Password must be at least 8 characters")
.refine((val) => /[A-Z]/.test(val), "Must contain at least one uppercase letter")
.refine((val) => /[a-z]/.test(val), "Must contain at least one lowercase letter")
.refine((val) => /[0-9]/.test(val), "Must contain at least one number")
.refine(
(val) => /[^A-Za-z0-9]/.test(val),
"Must contain at least one special character"
);
// URL slug validation and transformation
export const SlugSchema = z
.string()
.min(1)
.max(100)
.transform((val) => val.toLowerCase().replace(/\s+/g, "-"))
.refine(
(val) => /^[a-z0-9-]+$/.test(val),
"Slug can only contain lowercase letters, numbers, and hyphens"
);
// Async username validation (checks database)
export const UsernameSchema = z.string().min(3).max(20).refine(
async (username) => {
// Simulated database check
// const exists = await db.user.findUnique({ where: { username } });
// return !exists;
return true; // Replace with actual check
},
{ message: "Username is already taken" }
);
// ============================================================================
// CODECS (BIDIRECTIONAL TRANSFORMATIONS)
// ============================================================================
// Date codec: ISO string <-> Date object
export const DateCodec = z.codec(
z.iso.datetime(), // Input: ISO string
z.date(), // Output: Date object
{
decode: (str) => new Date(str),
encode: (date) => date.toISOString(),
}
);
// JSON codec: string <-> object
export const JSONCodec = <T extends z.ZodTypeAny>(schema: T) =>
z.codec(
z.string(),
schema,
{
decode: (str) => JSON.parse(str),
encode: (obj) => JSON.stringify(obj),
}
);
// Usage: const UserJSONCodec = JSONCodec(UserResponse);
// ============================================================================
// COMPOSABLE SCHEMAS
// ============================================================================
// Base timestamp fields
export const TimestampSchema = z.object({
createdAt: DateCodec,
updatedAt: DateCodec,
});
// Base author fields
export const AuthorSchema = z.object({
authorId: z.string().uuid(),
authorName: z.string(),
});
// Combine into larger schemas
export const PostSchema = z
.object({
id: z.string().uuid(),
title: z.string().min(1).max(200),
content: z.string(),
slug: SlugSchema,
published: z.boolean().default(false),
tags: z.array(z.string()).max(10),
})
.merge(TimestampSchema)
.merge(AuthorSchema);
export type Post = z.infer<typeof PostSchema>;
// ============================================================================
// RECURSIVE TYPES
// ============================================================================
interface Category {
id: string;
name: string;
subcategories: Category[];
}
export const CategorySchema: z.ZodType<Category> = z.lazy(() =>
z.object({
id: z.string().uuid(),
name: z.string().min(1).max(100),
subcategories: z.array(CategorySchema),
})
);
// ============================================================================
// FILE UPLOAD VALIDATION
// ============================================================================
export const ImageUploadSchema = z.object({
file: z
.instanceof(File)
.refine((file) => file.size <= 5 * 1024 * 1024, "File must be less than 5MB")
.refine(
(file) => ["image/jpeg", "image/png", "image/webp"].includes(file.type),
"Only JPEG, PNG, and WebP images are allowed"
),
alt: z.string().max(200).optional(),
});
export type ImageUpload = z.infer<typeof ImageUploadSchema>;
// ============================================================================
// QUERY PARAMETERS
// ============================================================================
export const PaginationQuerySchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(100).default(10),
sortBy: z.string().optional(),
sortOrder: z.enum(["asc", "desc"]).default("asc"),
});
export type PaginationQuery = z.infer<typeof PaginationQuerySchema>;
export const SearchQuerySchema = PaginationQuerySchema.extend({
q: z.string().min(1).max(200),
filters: z
.string()
.optional()
.transform((val) => (val ? JSON.parse(val) : {})),
});
export type SearchQuery = z.infer<typeof SearchQuerySchema>;
// ============================================================================
// WEBHOOK VALIDATION
// ============================================================================
export const WebhookPayloadSchema = z.object({
id: z.string().uuid(),
event: z.enum([
"user.created",
"user.updated",
"user.deleted",
"order.created",
"order.fulfilled",
]),
timestamp: DateCodec,
data: z.record(z.any()),
signature: z.string(),
});
export type WebhookPayload = z.infer<typeof WebhookPayloadSchema>;
// ============================================================================
// CONFIGURATION SCHEMAS
// ============================================================================
export const AppConfigSchema = z.object({
app: z.object({
name: z.string(),
version: z.string().regex(/^\d+\.\d+\.\d+$/),
environment: z.enum(["development", "staging", "production"]),
}),
database: z.object({
host: z.string(),
port: z.coerce.number().int().positive(),
name: z.string(),
ssl: z.boolean().default(true),
}),
cache: z.object({
enabled: z.boolean().default(true),
ttl: z.number().int().positive().default(3600),
}),
features: z.record(z.boolean()).default({}),
});
export type AppConfig = z.infer<typeof AppConfigSchema>;
// ============================================================================
// ERROR HANDLING UTILITIES
// ============================================================================
export function formatZodError(error: z.ZodError): Record<string, string[]> {
const flattened = z.flattenError(error);
return flattened.fieldErrors as Record<string, string[]>;
}
export function getFirstError(error: z.ZodError): string {
return error.issues[0]?.message || "Validation failed";
}
export function prettyPrintErrors(error: z.ZodError): string {
return z.prettifyError(error);
}
Zod Ecosystem Integrations
Complete guide for integrating Zod with popular frameworks, libraries, and tools.
Last Updated: 2025-11-17
---
ESLint Plugins
eslint-plugin-zod-x
GitHub: https://github.com/JoshuaKGoldberg/eslint-plugin-zod-x (40 stars)
Enforces Zod best practices and coding standards.
Rules:
zod-x/no-missing-error-messages- Ensure custom error messages for better UXzod-x/prefer-enum- Preferz.enum()overz.union()of literals (better performance)zod-x/require-strict- Enforce strict object schemas (prevent extra properties)
Installation:
bun add -D eslint-plugin-zod-xConfiguration:
// .eslintrc.js
module.exports = {
plugins: ['zod-x'],
rules: {
'zod-x/no-missing-error-messages': 'warn',
'zod-x/prefer-enum': 'error',
'zod-x/require-strict': 'warn',
},
};---
eslint-plugin-import-zod
GitHub: https://github.com/nodkz/eslint-plugin-import-zod (46 stars)
Enforces consistent Zod import style.
Enforced Style:
// ✓ Correct
import { z } from "zod";
// ✗ Disallowed
import * as z from "zod";Installation:
bun add -D eslint-plugin-import-zodConfiguration:
// .eslintrc.js
module.exports = {
plugins: ['import-zod'],
rules: {
'import-zod/require-z-import': 'error',
},
};---
Framework Integrations
tRPC
GitHub: https://github.com/trpc/trpc (38,863 stars)
End-to-end typesafe APIs with automatic client generation.
Example:
import { z } from "zod";
import { initTRPC } from "@trpc/server";
const t = initTRPC.create();
export const appRouter = t.router({
getUser: t.procedure
.input(z.object({ id: z.string() }))
.query(({ input }) => {
return db.user.findUnique({ where: { id: input.id } });
}),
createUser: t.procedure
.input(z.object({
email: z.string().email(),
name: z.string(),
}))
.mutation(async ({ input }) => {
return db.user.create({ data: input });
}),
});Benefits:
- Full type safety from server to client
- No code generation needed
- Automatic input validation
- Works seamlessly with Zod schemas
Learn more: https://trpc.io
---
React Hook Form
GitHub: https://github.com/react-hook-form/react-hook-form (43,789 stars)
High-performance form validation for React.
Installation:
bun add react-hook-form @hookform/resolvers zodExample:
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const FormSchema = z.object({
email: z.string().email("Invalid email"),
password: z.string().min(8, "Password too short"),
age: z.number().int().min(18, "Must be 18+"),
});
type FormData = z.infer<typeof FormSchema>;
function MyForm() {
const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(FormSchema),
});
const onSubmit = (data: FormData) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("email")} />
{errors.email && <p>{errors.email.message}</p>}
<input type="password" {...register("password")} />
{errors.password && <p>{errors.password.message}</p>}
<input type="number" {...register("age", { valueAsNumber: true })} />
{errors.age && <p>{errors.age.message}</p>}
<button type="submit">Submit</button>
</form>
);
}Note: For comprehensive React Hook Form + Zod patterns, use the react-hook-form-zod skill.
---
Prisma
GitHub: https://github.com/prisma/prisma (41,734 stars)
Generate Zod schemas from Prisma models.
Installation:
bun add -D zod-prisma-typesPrisma Schema:
// prisma/schema.prisma
generator zod {
provider = "zod-prisma-types"
output = "../src/zod"
}
model User {
id String @id @default(uuid())
email String @unique
name String
createdAt DateTime @default(now())
}Generated Zod Schema:
// src/zod/user.ts (auto-generated)
export const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string(),
createdAt: z.date(),
});Usage:
import { UserSchema } from "@/zod/user";
// Validate user data
const result = UserSchema.safeParse(userData);---
NestJS
GitHub: https://github.com/nestjs/nest (69,342 stars)
Integration via nestjs-zod package.
Features:
- Automatic DTO generation
- OpenAPI documentation
- Validation pipes
- Exception filters
Installation:
bun add nestjs-zod zodExample:
import { createZodDto } from 'nestjs-zod';
import { z } from 'zod';
const CreateUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1),
age: z.number().int().positive().optional(),
});
class CreateUserDto extends createZodDto(CreateUserSchema) {}
@Controller('users')
export class UsersController {
@Post()
create(@Body() dto: CreateUserDto) {
// dto is validated automatically
return this.usersService.create(dto);
}
}Learn more: https://github.com/risenforces/nestjs-zod
---
Code Generation Tools
Orval
GitHub: https://github.com/anymaniax/orval (4,848 stars)
Generate Zod schemas and API clients from OpenAPI specifications.
Installation:
bun add -D orvalConfiguration:
// orval.config.js
module.exports = {
petstore: {
input: './openapi.yaml',
output: {
mode: 'split',
target: './src/api',
schemas: './src/schemas',
client: 'fetch',
override: {
mutator: {
path: './src/mutator/custom-fetch.ts',
name: 'customFetch',
},
},
},
},
};Generate:
bunx orvalLearn more: https://orval.dev
---
Hey API
GitHub: https://github.com/hey-api/openapi-ts (3,497 stars)
OpenAPI to TypeScript with Zod support.
Installation:
bun add -D @hey-api/openapi-tsGenerate:
npx @hey-api/openapi-ts -i ./openapi.json -o ./src/api -c fetchLearn more: https://heyapi.vercel.app
---
Kubb
GitHub: https://github.com/kubb-project/kubb (1,416 stars)
Modern API toolkit with Zod code generation.
Installation:
bun add -D @kubb/core @kubb/plugin-zodConfiguration:
// kubb.config.ts
export default {
input: './openapi.yaml',
output: './src/gen',
plugins: [
['@kubb/plugin-zod', {
output: './schemas',
}],
],
};Learn more: https://kubb.dev
---
Testing Libraries
Vitest
Zod works seamlessly with Vitest for schema testing:
import { describe, it, expect } from 'vitest';
import { z } from 'zod';
describe('UserSchema', () => {
const UserSchema = z.object({
email: z.string().email(),
age: z.number().int().positive(),
});
it('validates valid user', () => {
const result = UserSchema.safeParse({
email: 'user@example.com',
age: 25,
});
expect(result.success).toBe(true);
});
it('rejects invalid email', () => {
const result = UserSchema.safeParse({
email: 'invalid',
age: 25,
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toContain('email');
}
});
});---
Database Integrations
Drizzle ORM
GitHub: https://github.com/drizzle-team/drizzle-orm (27,536 stars)
Type-safe SQL ORM with Zod integration.
Example:
import { z } from 'zod';
import { pgTable, text, integer } from 'drizzle-orm/pg-core';
import { createInsertSchema, createSelectSchema } from 'drizzle-zod';
const users = pgTable('users', {
id: text('id').primaryKey(),
email: text('email').notNull(),
age: integer('age'),
});
// Generate Zod schemas from table
const insertUserSchema = createInsertSchema(users);
const selectUserSchema = createSelectSchema(users);
// Validate before insert
const result = insertUserSchema.safeParse(userData);
if (result.success) {
await db.insert(users).values(result.data);
}---
API Development
Hono
GitHub: https://github.com/honojs/hono (24,883 stars)
Ultrafast web framework with Zod validation middleware.
Example:
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
const app = new Hono();
const CreateUserSchema = z.object({
email: z.string().email(),
name: z.string(),
});
app.post('/users', zValidator('json', CreateUserSchema), (c) => {
const user = c.req.valid('json');
// user is typed and validated
return c.json({ id: 123, ...user });
});---
Best Practices
1. Use code generation tools for OpenAPI → Zod conversion 2. Leverage ecosystem integrations instead of custom validation 3. Share schemas between frontend and backend with tRPC 4. Use ESLint plugins to enforce best practices 5. Generate Zod from database schemas for consistency 6. Integrate with forms using React Hook Form or similar
---
See also:
type-inference.mdfor generating types from schemaserror-handling.mdfor framework-specific error handling
Zod Error Handling Guide
Complete guide for handling, formatting, and customizing Zod validation errors.
Last Updated: 2025-11-17
---
Error Structure
When validation fails, Zod provides a ZodError object with detailed information:
const result = schema.safeParse(data);
if (!result.success) {
// ZodError structure
result.error.issues.forEach((issue) => {
console.log(issue.code); // Error type
console.log(issue.path); // Field path
console.log(issue.message); // Error message
});
}---
Error Formatting Utilities
Zod v4 provides three powerful utilities for formatting ZodError objects into more usable formats.
z.flattenError() - Best for Flat Schemas and Forms
Converts errors into a flat object structure with top-level and field-specific errors:
const FormSchema = z.object({
username: z.string().min(3),
email: z.string().email(),
age: z.number().int().positive(),
});
const result = FormSchema.safeParse({
username: "ab",
email: "not-an-email",
age: -5,
});
if (!result.success) {
const flattened = z.flattenError(result.error);
console.log(flattened.formErrors);
// [] - No top-level errors
console.log(flattened.fieldErrors);
/*
{
username: ["String must contain at least 3 character(s)"],
email: ["Invalid email"],
age: ["Number must be greater than 0"]
}
*/
// Access specific field errors
console.log(flattened.fieldErrors.username);
// ["String must contain at least 3 character(s)"]
}When to use: Single-level schemas, form validation, displaying field-specific errors in UI.
---
z.treeifyError() - Best for Nested Data Structures
Converts errors into a nested tree mirroring your schema structure:
const NestedSchema = z.object({
user: z.object({
profile: z.object({
name: z.string().min(1),
email: z.string().email(),
}),
settings: z.object({
notifications: z.boolean(),
}),
}),
posts: z.array(z.object({
title: z.string(),
content: z.string(),
})),
});
const result = NestedSchema.safeParse({
user: {
profile: {
name: "",
email: "invalid",
},
settings: {
notifications: "yes", // Should be boolean
},
},
posts: [
{ title: "Post 1", content: 123 }, // Content should be string
],
});
if (!result.success) {
const tree = z.treeifyError(result.error);
// Tree structure mirrors schema
console.log(tree.errors);
// [] - No errors at root level
// Navigate nested errors with optional chaining (IMPORTANT!)
console.log(tree.properties?.user?.properties?.profile?.properties?.name?.errors);
// ["String must contain at least 1 character(s)"]
console.log(tree.properties?.user?.properties?.profile?.properties?.email?.errors);
// ["Invalid email"]
console.log(tree.properties?.user?.properties?.settings?.properties?.notifications?.errors);
// ["Expected boolean, received string"]
// Array errors use 'items' property
console.log(tree.properties?.posts?.items?.[0]?.properties?.content?.errors);
// ["Expected string, received number"]
}Tree Structure:
interface ErrorTree {
errors: string[]; // Errors at current level
properties?: { // Object property errors
[key: string]: ErrorTree;
};
items?: ErrorTree[]; // Array item errors
}Best Practice: Always use optional chaining (?.) when accessing nested tree properties to prevent runtime errors.
When to use: Nested schemas, complex data structures, displaying errors next to nested form fields.
---
z.prettifyError() - Best for Debugging and Logging
Generates a human-readable string representation of all validation errors:
const UserSchema = z.object({
profile: z.object({
username: z.string().min(3),
email: z.string().email(),
}),
favoriteNumbers: z.array(z.number()),
});
const result = UserSchema.safeParse({
profile: {
username: "ab",
email: "not-email",
},
favoriteNumbers: ["one", "two"],
});
if (!result.success) {
const pretty = z.prettifyError(result.error);
console.log(pretty);
}
/*
Output:
✖ String must contain at least 3 character(s)
→ at profile.username
✖ Invalid email
→ at profile.email
✖ Expected number, received string
→ at favoriteNumbers[0]
✖ Expected number, received string
→ at favoriteNumbers[1]
*/When to use: Development logging, error debugging, console output, error monitoring services.
---
Comparison Table
| Method | Best For | Output Type | Nested Support |
|---|---|---|---|
z.flattenError() | Forms, single-level schemas | Object { formErrors, fieldErrors } | No |
z.treeifyError() | Nested data, complex structures | Tree object | Yes |
z.prettifyError() | Debugging, logging | String | Yes |
---
Legacy Methods (Deprecated)
// ❌ Zod v3 (Deprecated in v4)
error.format(); // Use z.treeifyError(error) instead
error.flatten(); // Use z.flattenError(error) instead
// ✅ Zod v4
z.treeifyError(error);
z.flattenError(error);---
Custom Error Messages
Zod v4 unifies error customization with the error parameter, replacing the fragmented message, invalid_type_error, required_error, and errorMap options from v3.
Three-Level Error Customization System
Zod provides three levels of error customization with clear precedence:
// 1. SCHEMA-LEVEL (Highest Priority)
// Define custom messages when creating schemas
const NameSchema = z.string({
error: "Name must be a string",
});
const EmailSchema = z.string().email({
error: (issue) => {
if (issue.code === "invalid_string") {
return { message: "Please provide a valid email address" };
}
},
});
const AgeSchema = z.number().min(18, {
error: "Must be at least 18 years old",
});
// 2. PER-PARSE LEVEL (Medium Priority)
// Override errors for a specific parse call
const result = UserSchema.parse(data, {
error: (issue) => {
// Custom error logic for this specific parse
return { message: `Validation failed at ${issue.path.join('.')}` };
},
});
// 3. GLOBAL LEVEL (Lowest Priority)
// Set application-wide error defaults
z.config({
customError: (issue) => {
// Global error handler - applies when schema/parse don't specify
return { message: `Global error: ${issue.code}` };
},
});---
Error Function Parameters
Error customization functions receive an issue context object with detailed information:
z.string().min(5, {
error: (issue) => {
// Available properties:
console.log(issue.code); // Error type (e.g., "too_small")
console.log(issue.input); // The data being validated
console.log(issue.inst); // The schema instance
console.log(issue.path); // Path in nested structures
// Type-specific properties
if (issue.code === "too_small") {
console.log(issue.minimum); // The minimum value
console.log(issue.inclusive); // Whether minimum is inclusive
}
// Return undefined to defer to next handler in precedence chain
return undefined;
},
});---
Quick Examples
// Simple string message
z.string().min(5, "Must be at least 5 characters");
z.string("Invalid string!");
// Conditional error messages
z.string({
error: (issue) => {
if (issue.code === "too_small") {
return { message: `Minimum length: ${issue.minimum}` };
}
if (issue.code === "invalid_type") {
return { message: `Expected string, got ${issue.received}` };
}
return undefined; // Use default message
},
});
// Include input data in errors (disabled by default for security)
schema.parse(data, {
reportInput: true, // Now error.issues will include input data
error: (issue) => ({
message: `Invalid value: ${JSON.stringify(issue.input)}`,
}),
});---
Localization Support
Zod v4 includes built-in support for 40+ locales:
import { z } from "zod";
// Set global locale
z.config(z.locales.en()); // English (default)
z.config(z.locales.es()); // Spanish
z.config(z.locales.fr()); // French
z.config(z.locales.de()); // German
z.config(z.locales.ja()); // Japanese
z.config(z.locales.zh()); // Chinese
// ... and 34+ more locales
// Per-parse locale override
const result = schema.parse(data, {
locale: z.locales.es(),
});
// Custom i18n integration
z.config({
customError: (issue) => ({
message: t(`validation.${issue.code}`, issue),
}),
});Available Locales: ar, bg, cs, da, de, el, en, es, et, fa, fi, fr, he, hi, hr, hu, id, it, ja, ko, lt, lv, nb, nl, pl, pt, ro, ru, sk, sl, sr, sv, th, tr, uk, vi, zh, zh-TW
---
Common Error Handling Patterns
Pattern 1: Form Validation with Field Errors
const LoginSchema = z.object({
email: z.string().email("Invalid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
});
const result = LoginSchema.safeParse(formData);
if (!result.success) {
const { fieldErrors } = z.flattenError(result.error);
// Display errors in UI
if (fieldErrors.email) {
setEmailError(fieldErrors.email[0]);
}
if (fieldErrors.password) {
setPasswordError(fieldErrors.password[0]);
}
}---
Pattern 2: API Response Error Handling
const ApiResponseSchema = z.object({
status: z.enum(["success", "error"]),
data: z.any(),
});
const result = ApiResponseSchema.safeParse(response);
if (!result.success) {
// Log detailed error for debugging
console.error(z.prettifyError(result.error));
// Return user-friendly error
return {
error: "Invalid server response. Please try again.",
};
}---
Pattern 3: Nested Form Validation
const ProfileSchema = z.object({
personal: z.object({
firstName: z.string().min(1),
lastName: z.string().min(1),
}),
contact: z.object({
email: z.string().email(),
phone: z.string().optional(),
}),
});
const result = ProfileSchema.safeParse(formData);
if (!result.success) {
const tree = z.treeifyError(result.error);
// Access nested errors with optional chaining
const firstNameError = tree.properties?.personal?.properties?.firstName?.errors?.[0];
const emailError = tree.properties?.contact?.properties?.email?.errors?.[0];
setFieldError("personal.firstName", firstNameError);
setFieldError("contact.email", emailError);
}---
Pattern 4: Array Validation Errors
const TodoListSchema = z.object({
todos: z.array(z.object({
title: z.string().min(1),
completed: z.boolean(),
})),
});
const result = TodoListSchema.safeParse(data);
if (!result.success) {
const tree = z.treeifyError(result.error);
// Access array item errors
tree.properties?.todos?.items?.forEach((item, index) => {
const titleError = item.properties?.title?.errors?.[0];
if (titleError) {
console.log(`Todo ${index}: ${titleError}`);
}
});
}---
Error Code Reference
Common Zod error codes you'll encounter:
| Code | Description | Example |
|---|---|---|
invalid_type | Wrong data type | Expected string, got number |
too_small | Value below minimum | String length < 5 |
too_big | Value above maximum | Number > 100 |
invalid_string | String format invalid | Email validation failed |
invalid_enum_value | Not in enum | Value not in ["a", "b", "c"] |
custom | Custom refinement failed | Password doesn't match |
invalid_union | No union branch matched | Neither string nor number |
invalid_date | Invalid Date object | NaN date |
---
Best Practices
1. Use `.safeParse()` in production - Prevents unhandled exceptions 2. Choose the right formatter:
- Forms →
z.flattenError() - Nested data →
z.treeifyError() - Debugging →
z.prettifyError()
3. Always use optional chaining with z.treeifyError() results 4. Provide clear error messages at schema level for better UX 5. Log detailed errors for debugging, but show user-friendly messages in UI 6. Use localization for internationalized applications 7. Leverage error codes for programmatic error handling
---
See also:
migration-guide.mdfor v3 to v4 error API changesadvanced-patterns.mdfor custom refinements and validation
// ESLint 9+ flat config with Zod best practices
import eslint from "@eslint/js";
import tseslint from "@typescript-eslint/eslint-plugin";
import tsparser from "@typescript-eslint/parser";
import zodX from "eslint-plugin-zod-x";
export default [
eslint.configs.recommended,
{
files: ["**/*.ts", "**/*.tsx"],
languageOptions: {
parser: tsparser,
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
project: "./tsconfig.json",
},
},
plugins: {
"@typescript-eslint": tseslint,
"zod-x": zodX,
},
rules: {
// TypeScript rules
"@typescript-eslint/no-unused-vars": [
"error",
{ argsIgnorePattern: "^_" },
],
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/explicit-function-return-type": "off",
// Zod-specific rules (eslint-plugin-zod-x)
"zod-x/no-missing-error-messages": "warn",
"zod-x/prefer-enum": "warn",
"zod-x/require-strict": "error",
},
},
{
ignores: ["dist/**", "node_modules/**", "*.config.js"],
},
];
// Legacy .eslintrc.json example (ESLint 8 and below):
// {
// "parser": "@typescript-eslint/parser",
// "parserOptions": {
// "ecmaVersion": "latest",
// "sourceType": "module",
// "project": "./tsconfig.json"
// },
// "plugins": ["@typescript-eslint", "zod-x"],
// "extends": [
// "eslint:recommended",
// "plugin:@typescript-eslint/recommended"
// ],
// "rules": {
// "zod-x/no-missing-error-messages": "warn",
// "zod-x/prefer-enum": "warn",
// "zod-x/require-strict": "error"
// }
// }
Migrating from Zod v3 to v4
Complete guide for upgrading from Zod v3 to v4, including all breaking changes and migration strategies.
Last Updated: 2025-11-17
---
Breaking Changes Overview
Zod v4 introduces several breaking changes that improve performance and API consistency. This guide covers all changes you need to be aware of when upgrading.
---
1. Error Customization Unified
Breaking Change: The error parameter replaces fragmented error options.
// ❌ Zod v3 (No longer works)
z.string({
message: "Custom message",
invalid_type_error: "Must be a string",
required_error: "Field is required"
});
z.string().email({ errorMap: (issue) => ({ message: "Invalid email" }) });
// ✅ Zod v4 (Use unified 'error' parameter)
z.string({
error: "Custom message"
});
z.string().email({
error: (issue) => ({ message: "Invalid email" })
});---
2. Number Validation Stricter
Breaking Change: Infinite values and unsafe integers are now rejected.
// ❌ Zod v3 (Accepted these values)
z.number().parse(Infinity); // OK in v3
z.number().parse(-Infinity); // OK in v3
z.number().int().parse(9007199254740992); // OK in v3 (unsafe integer)
// ✅ Zod v4 (Rejects invalid numbers)
z.number().parse(Infinity); // ✗ Error: infinite values rejected
z.number().parse(-Infinity); // ✗ Error: infinite values rejected
z.number().int().parse(9007199254740992); // ✗ Error: outside safe integer range
// If you need to allow infinite values, use a refinement:
z.number().refine((n) => Number.isFinite(n) || !Number.isNaN(n));
// .int() now enforces Number.MIN_SAFE_INTEGER to Number.MAX_SAFE_INTEGER
// .safe() no longer permits floats (integers only)---
3. String Format Methods Moved to Top-Level
Breaking Change: Format validators are now top-level functions, not methods.
// ❌ Zod v3 (Methods on z.string())
z.string().email();
z.string().uuid();
z.string().url();
z.string().ipv4();
z.string().ipv6();
// ✅ Zod v4 (Top-level functions)
z.email(); // Shorthand for validated email
z.uuid(); // Stricter UUID validation (RFC 9562/4122)
z.url();
z.ipv4();
z.ipv6();
// Both still work for now, but top-level is preferred
z.string().email(); // Still works in v4
z.email(); // Preferred in v4---
4. Object Defaults Behavior Changed
Breaking Change: Defaults inside properties are now applied even within optional fields.
const schema = z.object({
name: z.string().default("Anonymous"),
age: z.number().optional().default(18),
});
// ❌ Zod v3 behavior
schema.parse({ age: undefined });
// Result: { name: "Anonymous", age: undefined }
// ✅ Zod v4 behavior
schema.parse({ age: undefined });
// Result: { name: "Anonymous", age: 18 }
// Default is applied even though field was optional---
5. Deprecated APIs Removed or Changed
Breaking Changes: Several APIs have been deprecated or consolidated.
// ❌ Zod v3 APIs (Deprecated in v4)
schema1.merge(schema2); // Use .extend() instead
error.format(); // Use z.treeifyError(error)
error.flatten(); // Use z.flattenError(error)
z.nativeEnum(MyEnum); // Use z.enum() (now handles both)
z.promise(schema); // Deprecated
// ✅ Zod v4 Replacements
schema1.extend(schema2); // Preferred way to merge
z.treeifyError(error); // New error formatting
z.flattenError(error); // New flat error format
z.enum(MyEnum); // Unified enum handling
// No direct replacement for z.promise() - use async refinements---
6. Function Validation Redesigned
Breaking Change: z.function() no longer returns a schema directly.
// ❌ Zod v3
const myFunc = z.function()
.args(z.string())
.returns(z.number())
.parse(someFunction);
// ✅ Zod v4
const myFunc = z.function()
.args(z.string())
.returns(z.number())
.implement((str) => {
return parseInt(str); // Type-checked!
});
// Or with new syntax:
const myFunc = z.function({
input: [z.string()],
output: z.number()
}).implement((str) => parseInt(str));---
7. UUID Validation Stricter
Breaking Change: UUID validation now follows RFC 9562/4122 specification strictly.
// Some previously valid UUIDs may now fail validation
// Ensure UUIDs conform to RFC 9562/4122 format
const uuid = z.uuid().parse("550e8400-e29b-41d4-a716-446655440000"); // ✓---
Migration Checklist
Use this checklist to systematically upgrade your codebase:
- [ ] Replace
message,invalid_type_error,required_error,errorMapwith unifiederrorparameter - [ ] Check for
Infinityor-Infinityin number validations - [ ] Update
.int()usage if relying on unsafe integers - [ ] Replace
.merge()with.extend() - [ ] Replace
error.format()withz.treeifyError() - [ ] Replace
error.flatten()withz.flattenError() - [ ] Update
z.nativeEnum()toz.enum()(or keep for clarity) - [ ] Replace
z.promise()with async refinements - [ ] Update function validation to use
.implement()or new syntax - [ ] Consider using top-level format functions (
z.email()instead ofz.string().email()) - [ ] Test UUID validation if using custom UUID formats
---
Performance Improvements
Zod v4 eliminates the ZodEffects class, moving refinements directly into schemas and introducing ZodTransform for dedicated transformation handling. This results in:
- Faster validation - Direct schema integration reduces overhead
- Better tree-shaking - Unused features are easier to eliminate
- Improved type inference - Simpler internal structure
---
Gradual Migration Strategy
If you have a large codebase, consider this gradual approach:
Step 1: Install Zod v4
bun add zod@4Step 2: Run Tests
Run your entire test suite to identify breaking changes:
bun testStep 3: Fix Error Customization First
Search for old error customization patterns:
// Search for: invalid_type_error, required_error, errorMap
// Replace with: error parameterStep 4: Update Error Formatting
Search for legacy error formatting:
// Search for: error.format(), error.flatten()
// Replace with: z.treeifyError(error), z.flattenError(error)Step 5: Update API Methods
Search for deprecated methods:
// Search for: .merge(
// Replace with: .extend(Step 6: Test Number Validation
If you use .int() or allow infinite numbers, add tests to verify behavior.
Step 7: Re-run Tests
Verify all tests pass with Zod v4.
---
Common Migration Patterns
Pattern 1: Form Validation Migration
// ❌ Zod v3
const FormSchema = z.object({
email: z.string({
required_error: "Email required",
invalid_type_error: "Email must be string"
}).email({ message: "Invalid email" })
});
// ✅ Zod v4
const FormSchema = z.object({
email: z.string({
error: "Email required"
}).email({
error: "Invalid email"
})
});Pattern 2: API Validation Migration
// ❌ Zod v3
const result = schema.safeParse(data);
if (!result.success) {
const errors = result.error.flatten();
return { errors: errors.fieldErrors };
}
// ✅ Zod v4
const result = schema.safeParse(data);
if (!result.success) {
const errors = z.flattenError(result.error);
return { errors: errors.fieldErrors };
}Pattern 3: Number Validation Migration
// ❌ Zod v3 (Allowed unsafe integers)
const IdSchema = z.number().int();
// ✅ Zod v4 (Explicitly handle large numbers)
const IdSchema = z.number().int(); // Now enforces safe integers
// If you need large integers, use bigint or refine
const IdSchema = z.bigint();
// or
const IdSchema = z.number().refine(Number.isInteger);---
Version Detection
To check which version of Zod you're using:
import { z } from "zod";
// Check for v4 features
if (typeof z.codec === 'function') {
console.log("Zod v4 detected");
} else {
console.log("Zod v3 or earlier");
}---
Rollback Strategy
If you need to rollback to Zod v3:
# Install last stable v3 version
bun add zod@3.23.8Then revert code changes using version control.
---
See also:
error-handling.mdfor new error formatting methodsadvanced-patterns.mdfor new codec and transform features
// NOTE: Always check the latest stable version of Zod on NPM and never just claim that the pinned versions doesnt exist: https://www.npmjs.com/package/zod
{
"name": "zod-project-example",
"version": "1.0.0",
"description": "Example project setup with Zod",
"main": "index.js",
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"test": "vitest",
"lint": "eslint .",
"type-check": "tsc --noEmit"
},
"keywords": [
"zod",
"validation",
"typescript",
"schema"
],
"author": "",
"license": "MIT",
"dependencies": {
"zod": "^4.3.6"
},
"devDependencies": {
"@types/node": "^20.0.0",
"typescript": "^5.9.3",
"tsx": "^4.0.0",
"vitest": "^2.0.0",
"eslint": "^9.0.0",
"eslint-plugin-zod-x": "^0.1.0",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0"
},
"peerDependencies": {
"typescript": ">=5.5.0"
},
"engines": {
"node": ">=18.0.0"
}
}
Zod Quick Reference Cheat Sheet
Version: Zod 4.x (4.1.12+)
Note: Some APIs shown here (z.codec, z.iso.*, error helpers) are Zod 4 only
Installation
bun add zod
# or
npm install zod
# or
pnpm add zod
# or
yarn add zodBasic Usage
import { z } from "zod";
// Define schema
const UserSchema = z.object({
name: z.string(),
age: z.number(),
});
// Infer type
type User = z.infer<typeof UserSchema>;
// Parse (throws on error)
const user = UserSchema.parse(data);
// Safe parse (no throw)
const result = UserSchema.safeParse(data);
if (result.success) {
console.log(result.data);
} else {
console.error(result.error);
}Primitives
| Type | Code |
|---|---|
| String | z.string() |
| Number | z.number() |
| Boolean | z.boolean() |
| Date | z.date() |
| BigInt | z.bigint() |
| Symbol | z.symbol() |
| Null | z.null() |
| Undefined | z.undefined() |
| Void | z.void() |
| Any | z.any() |
| Unknown | z.unknown() |
| Never | z.never() |
String Validators
| Validator | Code |
|---|---|
z.string().email() | |
| URL | z.string().url() |
| UUID | z.string().uuid() |
| Min length | z.string().min(5) |
| Max length | z.string().max(100) |
| Exact length | z.string().length(10) |
| Regex | z.string().regex(/pattern/) |
| Starts with | z.string().startsWith("pre") |
| Ends with | z.string().endsWith("suf") |
| Trim | z.string().trim() |
| Lowercase | z.string().toLowerCase() |
| Uppercase | z.string().toUpperCase() |
Number Validators
| Validator | Code |
|---|---|
| Integer | z.number().int() |
| Positive | z.number().positive() |
| Non-negative | z.number().nonnegative() |
| Negative | z.number().negative() |
| Non-positive | z.number().nonpositive() |
| Min | z.number().min(0) |
| Max | z.number().max(100) |
| Greater than | z.number().gt(0) |
| Greater/equal | z.number().gte(0) |
| Less than | z.number().lt(100) |
| Less/equal | z.number().lte(100) |
| Multiple of | z.number().multipleOf(5) |
Collections
| Type | Code |
|---|---|
| Array | z.array(z.string()) |
| Tuple | z.tuple([z.string(), z.number()]) |
| Object | z.object({ name: z.string() }) |
| Record | z.record(z.string()) |
| Map | z.map(z.string(), z.number()) |
| Set | z.set(z.string()) |
Special Types
| Type | Code |
|---|---|
| Enum | z.enum(["a", "b", "c"]) |
| Literal | z.literal("value") |
| Union | z.union([z.string(), z.number()]) |
| Discriminated Union | z.discriminatedUnion("type", [...]) |
| Intersection | z.intersection(schema1, schema2) |
Modifiers
| Modifier | Code |
|---|---|
| Optional | .optional() |
| Nullable | .nullable() |
| Nullish | .nullish() |
| Default | .default(value) |
| Catch | .catch(fallback) |
| Readonly | .readonly() |
| Brand | .brand<"BrandName">() |
Object Methods
| Method | Code |
|---|---|
| Extend | .extend({ field: z.string() }) |
| Pick | .pick({ name: true }) |
| Omit | .omit({ age: true }) |
| Partial | .partial() |
| Required | .required() |
| Deep Partial | .deepPartial() |
| Merge | .merge(otherSchema) |
| Keyof | .keyof() |
Validation
| Method | Code |
|---|---|
| Refine | .refine((val) => condition, message) |
| Super Refine | .superRefine((data, ctx) => { ... }) |
| Transform | .transform((val) => newVal) |
| Pipe | .pipe(otherSchema) |
Parsing
| Method | Returns | Throws? |
|---|---|---|
.parse(data) | T | Yes |
.safeParse(data) | { success: boolean, data?: T, error?: ZodError } | No |
.parseAsync(data) | Promise<T> | Yes |
.safeParseAsync(data) | Promise<{...}> | No |
Type Inference
// Infer output type
type User = z.infer<typeof UserSchema>;
// Get input type (for transforms)
type Input = z.input<typeof Schema>;
// Get output type (for transforms)
type Output = z.output<typeof Schema>;Error Handling
// Flatten errors (for forms)
const flattened = z.flattenError(error);
flattened.formErrors // Top-level errors
flattened.fieldErrors // Field-specific errors
// Tree structure (for nested data)
const tree = z.treeifyError(error);
tree.errors // Current level
tree.properties // Nested errors
// Pretty print
const pretty = z.prettifyError(error);Custom Errors
// Inline message
z.string().min(5, "Too short!");
// Error map
z.string({
error: (issue) => {
if (issue.code === "too_small") {
return { message: `Min: ${issue.minimum}` };
}
},
});
// Per-parse override
schema.parse(data, {
error: (issue) => "Custom error",
});Coercion
z.coerce.string() // Convert to string
z.coerce.number() // Convert to number
z.coerce.boolean() // Convert to boolean
z.coerce.bigint() // Convert to bigint
z.coerce.date() // Convert to DateCodecs
const DateCodec = z.codec(
z.iso.datetime(),
z.date(),
{
decode: (str) => new Date(str),
encode: (date) => date.toISOString(),
}
);
DateCodec.decode("2024-01-01T00:00:00Z"); // Date
DateCodec.encode(new Date()); // stringJSON Schema
const jsonSchema = z.toJSONSchema(schema, {
target: "openapi-3.0",
metadata: true,
cycles: "ref",
reused: "defs",
});Metadata
schema.meta({
id: "user_email",
title: "User Email",
description: "The user's email address",
});
schema.describe("User email"); // LegacyISO Formats
z.iso.date() // YYYY-MM-DD
z.iso.time() // HH:MM:SS
z.iso.datetime() // ISO 8601 datetime
z.iso.duration() // ISO 8601 durationNetwork Formats
z.ipv4() // IPv4 address
z.ipv6() // IPv6 address
z.cidrv4() // IPv4 CIDR
z.cidrv6() // IPv6 CIDROther Formats
z.jwt() // JWT token
z.nanoid() // Nanoid
z.cuid() // CUID
z.cuid2() // CUID2
z.ulid() // ULID
z.base64() // Base64
z.hex() // HexadecimalCommon Patterns
Environment Variables
const EnvSchema = z.object({
NODE_ENV: z.enum(["development", "production"]),
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(),
});
const env = EnvSchema.parse(process.env);API Request
const CreateUserRequest = z.object({
username: z.string().min(3).max(20),
email: z.string().email(),
password: z.string().min(8),
});
const result = CreateUserRequest.safeParse(req.body);Form Validation
const FormSchema = z.object({
email: z.string().email("Invalid email"),
password: z.string().min(8, "Too short"),
});Discriminated Union
const Response = z.discriminatedUnion("status", [
z.object({ status: z.literal("success"), data: z.any() }),
z.object({ status: z.literal("error"), message: z.string() }),
]);Recursive Type
interface Node {
value: string;
children: Node[];
}
const NodeSchema: z.ZodType<Node> = z.lazy(() =>
z.object({
value: z.string(),
children: z.array(NodeSchema),
})
);Performance Tips
1. Use z.discriminatedUnion() instead of z.union() when possible 2. Cache schema instances (define at module level) 3. Use .safeParse() over .parse() to avoid try-catch overhead 4. Lazy load large schemas with z.lazy() 5. Use coercion sparingly
Common Mistakes
❌ Don't recreate schemas on every request ✅ Define schemas at module level
❌ Don't use .refine() for transformations ✅ Use .transform() for data modifications
❌ Don't ignore TypeScript strict mode ✅ Enable "strict": true in tsconfig.json
❌ Don't use .parse() for user input without error handling ✅ Use .safeParse() and check result.success
Resources
- Official Docs: https://zod.dev
- GitHub: https://github.com/colinhacks/zod
- Playground: https://zod-playground.vercel.app
- Ecosystem: https://zod.dev/ecosystem
Zod Troubleshooting Guide
Solutions for common issues, performance optimization, and best practices.
Last Updated: 2025-11-17
---
Known Issues & Solutions
1. TypeScript Strict Mode Required
Issue: Zod requires TypeScript strict mode ("strict": true).
Error Message:
Type 'string | undefined' is not assignable to type 'string'Solution: Enable strict mode in tsconfig.json:
{
"compilerOptions": {
"strict": true,
// If enabling strict is impossible, manually enable strictNullChecks
"strictNullChecks": true
}
}Why: Zod's type inference relies on strict null checking to properly distinguish between string, string | undefined, and string | null.
---
2. Large Schema Bundle Size
Issue: Complex schemas can increase bundle size significantly.
Solution 1: Use lazy loading for large schemas:
// Instead of importing directly
import { HeavySchema } from './schemas/heavy';
// Lazy load when needed
const HeavySchema = z.lazy(() =>
import('./schemas/heavy').then(m => m.HeavySchema)
);Solution 2: Code splitting by route/page:
// Only load schema when route is accessed
const ProfileSchema = lazy(() => import('./schemas/profile'));Solution 3: Extract shared schemas:
// Reuse common schemas instead of duplicating
const TimestampSchema = z.object({
createdAt: z.date(),
updatedAt: z.date(),
});
// Use in multiple places
const PostSchema = z.object({
/* ... */
}).merge(TimestampSchema);
const CommentSchema = z.object({
/* ... */
}).merge(TimestampSchema);---
3. Async Refinements Performance
Issue: Async refinements (e.g., database checks) can be slow and cause performance bottlenecks.
Solution 1: Use caching:
const usernameCache = new Map();
const UsernameSchema = z.string().refine(
async (username) => {
if (usernameCache.has(username)) {
return usernameCache.get(username);
}
const exists = await checkUsernameExists(username);
usernameCache.set(username, !exists);
return !exists;
},
{ message: "Username already taken" }
);Solution 2: Debouncing:
import { debounce } from 'lodash';
const checkUsername = debounce(async (username) => {
return await checkUsernameExists(username);
}, 500);
const UsernameSchema = z.string().refine(
async (username) => {
const exists = await checkUsername(username);
return !exists;
},
{ message: "Username already taken" }
);Solution 3: Move expensive checks to background:
// Validate format synchronously
const QuickUsernameSchema = z.string().min(3).max(20).regex(/^[a-z0-9_]+$/);
// Validate availability asynchronously after submission
async function validateUsernameAvailability(username: string) {
const exists = await checkUsernameExists(username);
if (exists) {
throw new Error("Username already taken");
}
}---
4. Error Message Localization
Issue: Default error messages are English-only.
Solution: Use Zod v4's built-in localization or custom error maps:
Built-in Locales (40+ languages):
import { z } from "zod";
// Global locale
z.config(z.locales.es()); // Spanish
z.config(z.locales.fr()); // French
z.config(z.locales.ja()); // Japanese
// Per-parse locale
const result = schema.parse(data, {
locale: z.locales.es(),
});Custom i18n Integration:
import { t } from 'i18next';
z.config({
customError: (issue) => ({
message: t(`validation.${issue.code}`, issue),
}),
});Example i18n File:
{
"validation": {
"too_small": "Minimum length: {{minimum}}",
"too_big": "Maximum length: {{maximum}}",
"invalid_type": "Expected {{expected}}, got {{received}}",
"invalid_string": "Invalid {{validation}}"
}
}---
5. Circular Dependencies
Issue: Self-referential types can cause TypeScript errors.
Error Message:
'CategorySchema' is referenced directly or indirectly in its own type annotation.Solution: Use z.lazy():
interface Category {
name: string;
subcategories: Category[];
}
const CategorySchema: z.ZodType<Category> = z.lazy(() =>
z.object({
name: z.string(),
subcategories: z.array(CategorySchema),
})
);Why it works: z.lazy() defers schema creation until runtime, breaking the circular reference.
---
6. Union Type Performance
Issue: Large unions can slow down parsing significantly.
Slow Example:
// ✗ Tries all 10 branches on failure
const SlowUnion = z.union([
z.object({ type: z.literal("a"), data: z.string() }),
z.object({ type: z.literal("b"), data: z.number() }),
z.object({ type: z.literal("c"), data: z.boolean() }),
// ... 7 more branches
]);Solution: Use z.discriminatedUnion():
// ✓ Checks discriminator first, then only 1 branch
const FastUnion = z.discriminatedUnion("type", [
z.object({ type: z.literal("a"), data: z.string() }),
z.object({ type: z.literal("b"), data: z.number() }),
z.object({ type: z.literal("c"), data: z.boolean() }),
// ... 7 more branches
]);Performance gain: ~10x faster for large unions (10+ branches).
---
7. Default Values Not Applied on Undefined
Issue: .default() only applies when value is undefined, not for null or invalid types.
Example:
const schema = z.string().default("fallback");
schema.parse(undefined); // "fallback" ✓
schema.parse(null); // ✗ Error: Expected string, received null
schema.parse(123); // ✗ Error: Expected string, received numberSolution 1: Use .nullish().default() for null handling:
const schema = z.string().nullish().default("fallback");
schema.parse(undefined); // "fallback" ✓
schema.parse(null); // "fallback" ✓Solution 2: Use .catch() for fallback on any error:
const schema = z.string().catch("fallback");
schema.parse(undefined); // "fallback" ✓
schema.parse(null); // "fallback" ✓
schema.parse(123); // "fallback" ✓---
8. Transform vs Refine Confusion
Issue: Using .refine() when .transform() is needed (or vice versa).
Refine (validation only):
// ✓ For validation - returns boolean
z.string().refine((val) => val.length >= 8, "Too short");Transform (data modification):
// ✓ For transformation - returns new value
z.string().transform((val) => val.trim());Common Mistake:
// ✗ Wrong - refine doesn't modify data
z.string().refine((val) => val.trim());
// ✓ Correct - use transform to modify
z.string().transform((val) => val.trim());When to use what:
- Refine: Add validation rules (returns
true/false) - Transform: Modify the data (returns new value)
- Codec: Bidirectional transformation (encode + decode)
---
Performance Tips
1. Use .discriminatedUnion() instead of .union()
Impact: 5-10x faster for large unions
// ✗ Slow
z.union([...]);
// ✓ Fast
z.discriminatedUnion("type", [...]);---
2. Lazy Load Large Schemas
Impact: Reduces initial bundle size by 50-80%
const HeavySchema = z.lazy(() => import('./schemas/heavy'));---
3. Coerce Sparingly
Impact: Coercion adds ~20% overhead
// ✗ Slower
z.coerce.number()
// ✓ Faster (if input is already number)
z.number()---
4. Cache Schema Instances
Impact: Prevents recreation overhead
// ✗ Recreated on every request
app.post('/users', (req, res) => {
const schema = z.object({ ... }); // Bad!
});
// ✓ Created once
const UserSchema = z.object({ ... });
app.post('/users', (req, res) => {
UserSchema.parse(req.body); // Good!
});---
5. Use .safeParse() over .parse()
Impact: Avoids expensive try-catch
// ✗ Requires try-catch
try {
schema.parse(data);
} catch (err) {
// handle error
}
// ✓ No exceptions
const result = schema.safeParse(data);
if (!result.success) {
// handle error
}---
6. Avoid Deep Nesting
Impact: Flat schemas parse 2-3x faster
// ✗ Deeply nested
const Nested = z.object({
level1: z.object({
level2: z.object({
level3: z.object({
value: z.string()
})
})
})
});
// ✓ Flattened
const Flat = z.object({
level1_level2_level3_value: z.string()
});---
Best Practices
1. Define Schemas at Module Level
// ✓ Good - defined once
const UserSchema = z.object({
name: z.string(),
email: z.string().email(),
});
export function validateUser(data: unknown) {
return UserSchema.safeParse(data);
}
// ✗ Bad - recreated every call
export function validateUser(data: unknown) {
const schema = z.object({
name: z.string(),
email: z.string().email(),
});
return schema.safeParse(data);
}---
2. Use .safeParse() for User Input
// ✓ Best for user input
const result = schema.safeParse(userInput);
if (!result.success) {
return { errors: z.flattenError(result.error) };
}
// ✗ Can crash on invalid input
const data = schema.parse(userInput); // Throws!---
3. Leverage Type Inference
// ✓ Let Zod generate types
const UserSchema = z.object({
name: z.string(),
age: z.number(),
});
type User = z.infer<typeof UserSchema>;
// ✗ Manual types (out of sync risk)
interface User {
name: string;
age: number;
}---
4. Add Custom Error Messages
// ✓ Clear, actionable errors
z.string().min(8, "Password must be at least 8 characters");
z.string().email("Please enter a valid email address");
// ✗ Default errors (less user-friendly)
z.string().min(8);
z.string().email();---
5. Use Discriminated Unions
// ✓ Fast + good type inference
z.discriminatedUnion("type", [
z.object({ type: z.literal("success"), data: z.any() }),
z.object({ type: z.literal("error"), message: z.string() }),
]);
// ✗ Slow + poor type inference
z.union([
z.object({ type: z.literal("success"), data: z.any() }),
z.object({ type: z.literal("error"), message: z.string() }),
]);---
6. Validate Early
// ✓ Validate at system boundaries
app.post('/api/users', (req, res) => {
const result = UserSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ errors: result.error });
}
// Process validated data
});---
7. Compose Small Schemas
// ✓ Reusable, maintainable
const TimestampSchema = z.object({
createdAt: z.date(),
updatedAt: z.date(),
});
const PostSchema = z.object({
title: z.string(),
content: z.string(),
}).merge(TimestampSchema);
// ✗ Repetitive, error-prone
const PostSchema = z.object({
title: z.string(),
content: z.string(),
createdAt: z.date(),
updatedAt: z.date(),
});---
8. Document with .meta()
// ✓ Self-documenting schemas
const EmailSchema = z.string().email().meta({
title: "Email Address",
description: "User's primary email address",
examples: ["user@example.com"],
});
// ✗ Undocumented schemas
const EmailSchema = z.string().email();---
9. Test Schemas Thoroughly
// ✓ Comprehensive schema tests
describe('UserSchema', () => {
it('accepts valid user', () => {
const result = UserSchema.safeParse({
email: 'user@example.com',
age: 25,
});
expect(result.success).toBe(true);
});
it('rejects invalid email', () => {
const result = UserSchema.safeParse({
email: 'invalid',
age: 25,
});
expect(result.success).toBe(false);
});
it('rejects negative age', () => {
const result = UserSchema.safeParse({
email: 'user@example.com',
age: -5,
});
expect(result.success).toBe(false);
});
});---
10. Use Codecs for Serialization
// ✓ Bidirectional date conversion
const DateCodec = z.codec(
z.iso.datetime(),
z.date(),
{
decode: (str) => new Date(str),
encode: (date) => date.toISOString(),
}
);
// API request: Date → String
const payload = EventSchema.encode(event);
fetch('/api/events', { body: JSON.stringify(payload) });
// API response: String → Date
const event = EventSchema.decode(await response.json());---
See also:
migration-guide.mdfor upgrading from Zod v3error-handling.mdfor error message customizationadvanced-patterns.mdfor performance-optimized patterns
{
"compilerOptions": {
/* Language and Environment */
"target": "ES2022",
"lib": ["ES2022"],
"module": "ESNext",
"moduleResolution": "bundler",
/* Type Checking - STRICT MODE REQUIRED FOR ZOD */
"strict": true, // Required for Zod
"noUncheckedIndexedAccess": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noPropertyAccessFromIndexSignature": true,
"allowUnusedLabels": false,
"allowUnreachableCode": false,
/* Emit */
"outDir": "./dist",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"removeComments": true,
"noEmitOnError": true,
/* Interop Constraints */
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
/* Skip Lib Check */
"skipLibCheck": true,
/* Path Mapping */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
/* Advanced */
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
}
Zod Type Inference and Metadata Guide
Complete guide for TypeScript type inference, JSON Schema generation, and metadata system in Zod.
Last Updated: 2025-11-17
---
Type Inference Basics
Zod automatically generates TypeScript types from your schemas:
// Basic inference
const UserSchema = z.object({ name: z.string() });
type User = z.infer<typeof UserSchema>; // { name: string }---
Input vs Output Types
For schemas with transformations, Zod distinguishes between input and output types:
const TransformSchema = z.string().transform((s) => s.length);
type Input = z.input<typeof TransformSchema>; // string
type Output = z.output<typeof TransformSchema>; // number
// Use z.infer for output type (most common)
type Result = z.infer<typeof TransformSchema>; // number---
Complex Type Inference
Nested Objects
const ProfileSchema = z.object({
user: z.object({
name: z.string(),
age: z.number(),
}),
settings: z.object({
theme: z.enum(["light", "dark"]),
}),
});
type Profile = z.infer<typeof ProfileSchema>;
/*
{
user: {
name: string;
age: number;
};
settings: {
theme: "light" | "dark";
};
}
*/Arrays and Tuples
const TagsSchema = z.array(z.string());
type Tags = z.infer<typeof TagsSchema>; // string[]
const CoordinatesSchema = z.tuple([z.number(), z.number()]);
type Coordinates = z.infer<typeof CoordinatesSchema>; // [number, number]Unions and Discriminated Unions
const StatusSchema = z.union([
z.literal("pending"),
z.literal("success"),
z.literal("error"),
]);
type Status = z.infer<typeof StatusSchema>; // "pending" | "success" | "error"
const ResponseSchema = z.discriminatedUnion("status", [
z.object({ status: z.literal("success"), data: z.any() }),
z.object({ status: z.literal("error"), message: z.string() }),
]);
type Response = z.infer<typeof ResponseSchema>;
// { status: "success", data: any } | { status: "error", message: string }---
JSON Schema Conversion
Generate JSON Schema from Zod schemas for OpenAPI, AI structured outputs, or documentation:
const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
age: z.number().int().positive(),
role: z.enum(["admin", "user"]),
});
// Convert to JSON Schema
const jsonSchema = z.toJSONSchema(UserSchema);
/*
{
type: "object",
properties: {
id: { type: "string", format: "uuid" },
email: { type: "string", format: "email" },
age: { type: "number", minimum: 0, exclusiveMinimum: true },
role: { type: "string", enum: ["admin", "user"] }
},
required: ["id", "email", "age", "role"],
additionalProperties: false
}
*/JSON Schema Options
z.toJSONSchema(schema, {
target: "openapi-3.0", // Target version
metadata: true, // Include .meta() data
cycles: "ref", // Handle recursive schemas
reused: "defs", // Extract repeated schemas
io: "input", // Use input types instead of output
unrepresentable: "any", // Handle unsupported types
});---
Metadata System
Zod v4 provides a powerful metadata system for associating additional information with schemas, useful for documentation, code generation, AI structured outputs, and form validation.
Global Registry (Quick Start)
The easiest way to add metadata is using the global registry:
// Add metadata with .meta()
const EmailSchema = z.string().email().meta({
id: "email_address",
title: "Email Address",
description: "User's email address",
deprecated: false,
// Add any custom fields
placeholder: "user@example.com",
helpText: "We'll never share your email",
});
// Retrieve metadata
const meta = EmailSchema.meta();
console.log(meta.title); // "Email Address"
// .meta() without arguments retrieves existing metadata
const existingMeta = EmailSchema.meta();
// Legacy .describe() method (still supported for Zod 3 compatibility)
const DescribedSchema = z.string().describe("A user's name");
// Equivalent to: z.string().meta({ description: "A user's name" })Global Metadata Interface
The global registry accepts this interface by default:
interface GlobalMeta {
id?: string;
title?: string;
description?: string;
deprecated?: boolean;
[k: string]: unknown; // Any additional custom fields
}
// Extend with TypeScript declaration merging for type safety
declare module "zod" {
interface GlobalMeta {
placeholder?: string;
helpText?: string;
uiComponent?: "input" | "textarea" | "select";
}
}
// Now TypeScript knows about custom fields
const schema = z.string().meta({
placeholder: "Enter text...",
uiComponent: "textarea", // Autocomplete works!
});Custom Registries
For advanced use cases, create custom registries with strongly-typed metadata:
// Define custom metadata type
interface FormFieldMeta {
label: string;
placeholder?: string;
helpText?: string;
validation?: {
showOnChange?: boolean;
showOnBlur?: boolean;
};
}
// Create typed registry
const formRegistry = z.registry<FormFieldMeta>();
// Register schemas with metadata
const UsernameSchema = z.string().min(3).max(20);
formRegistry.add(UsernameSchema, {
label: "Username",
placeholder: "Choose a username",
helpText: "3-20 characters, alphanumeric only",
validation: {
showOnBlur: true,
},
});
// Check if schema exists
if (formRegistry.has(UsernameSchema)) {
// Retrieve metadata
const meta = formRegistry.get(UsernameSchema);
console.log(meta.label); // "Username"
}
// Remove schema from registry
formRegistry.remove(UsernameSchema);
// Clear entire registry
formRegistry.clear();.register() Method
The .register() method adds metadata and returns the original schema (not a new instance):
const EmailSchema = z.string().email().register({
title: "Email Address",
description: "User's email address",
});
// Returns the same schema instance, allowing inline registration
const UserSchema = z.object({
email: z.string().email().register({
id: "user_email",
title: "Email",
}),
name: z.string().register({
id: "user_name",
title: "Full Name",
}),
});Advanced: Inferred Types in Metadata
Reference schema types within metadata using z.$input and z.$output:
const TransformSchema = z.string().transform((s) => s.length);
const registry = z.registry<{
description: string;
// Use schema's input/output types
exampleInput?: z.$input<typeof TransformSchema>;
exampleOutput?: z.$output<typeof TransformSchema>;
}>();
registry.add(TransformSchema, {
description: "Converts string to length",
exampleInput: "hello", // Type: string
exampleOutput: 5, // Type: number
});Advanced: Schema Type Constraints
Restrict which schema types can be registered in a custom registry:
// Only allow string schemas
const stringRegistry = z.registry<
{ label: string },
z.ZodString
>();
const nameSchema = z.string();
stringRegistry.add(nameSchema, { label: "Name" }); // ✓ OK
const ageSchema = z.number();
stringRegistry.add(ageSchema, { label: "Age" }); // ✗ Type error!Metadata for JSON Schema Generation
Metadata integrates seamlessly with z.toJSONSchema():
const UserSchema = z.object({
email: z.string().email().meta({
title: "Email Address",
description: "The user's email",
examples: ["user@example.com"],
}),
age: z.number().int().positive().meta({
title: "Age",
description: "User's age in years",
minimum: 1,
maximum: 120,
}),
});
// Include metadata in JSON Schema output
const jsonSchema = z.toJSONSchema(UserSchema, {
metadata: true, // ← Includes .meta() data
});
/*
{
type: "object",
properties: {
email: {
type: "string",
format: "email",
title: "Email Address",
description: "The user's email",
examples: ["user@example.com"]
},
age: {
type: "number",
title: "Age",
description: "User's age in years",
minimum: 1,
maximum: 120
}
},
required: ["email", "age"]
}
*/---
Type Utilities
Extract Type from Schema
const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
});
// Extract the inferred type
type User = z.infer<typeof UserSchema>;
// Use in functions
function processUser(user: User) {
console.log(user.email); // TypeScript knows this is a string
}Use Schema as Type Guard
const isUser = (data: unknown): data is User => {
return UserSchema.safeParse(data).success;
};
// Use in code
if (isUser(data)) {
// TypeScript knows data is User
console.log(data.email);
}---
Advanced Type Patterns
Conditional Types with Refinements
const ConditionalSchema = z.object({
type: z.enum(["email", "phone"]),
value: z.string(),
}).refine(
(data) => {
if (data.type === "email") {
return z.string().email().safeParse(data.value).success;
}
return z.string().regex(/^\+?[1-9]\d{1,14}$/).safeParse(data.value).success;
},
{ message: "Invalid format for selected type" }
);
type ConditionalData = z.infer<typeof ConditionalSchema>;
// { type: "email" | "phone"; value: string }Brand Types for Domain Modeling
const UserId = z.string().brand<"UserId">();
const PostId = z.string().brand<"PostId">();
type UserId = z.infer<typeof UserId>; // string & Brand<"UserId">
type PostId = z.infer<typeof PostId>; // string & Brand<"PostId">
// Prevents mixing IDs
function getUser(id: UserId) { /* ... */ }
function getPost(id: PostId) { /* ... */ }
const userId = UserId.parse("user-123");
const postId = PostId.parse("post-456");
getUser(userId); // ✓ OK
getUser(postId); // ✗ Type error - prevents bugs!---
Best Practices
1. Use `z.infer` instead of manual types - Let Zod generate types 2. Add metadata for documentation - Improves DX and enables tooling 3. Use brands for ID types - Prevents accidental mixing 4. Generate JSON Schema for APIs - Keep OpenAPI docs in sync 5. Leverage input/output types - Essential for transforms/codecs 6. Use custom registries for complex metadata - Better type safety 7. Document schemas with `.meta()` - Especially for public APIs
---
See also:
advanced-patterns.mdfor transforms and codecserror-handling.mdfor type-safe error handling
Related skills
How it compares
Pick the zod skill over generic TypeScript helpers when you need Zod 4-specific APIs, safeParse patterns, and JSON Schema export guidance.
FAQ
Which Zod version does the zod skill document?
The zod skill documents Zod 4.x, specifically package version 4.1.12 or newer, including APIs that are not available in Zod 3.x such as updated error customization and stricter number validation.
What TypeScript setup does the zod skill require?
The zod skill requires TypeScript 5.5 or newer with strict mode enabled in tsconfig.json so z.infer and runtime validation stay aligned across API, form, and environment variable schemas.
How large is the Zod library covered by the zod skill?
The zod skill notes Zod's zero-dependency core is about 2kb gzipped, making it suitable for production API and form validation without significantly increasing bundle size.