zod
Zod
This skill provides guidance for implementing type-safe validation using Zod v4 in TypeScript applications. It covers schema design, error handling, type inference, and migration from Zod 3.
Zod 4 Requirements
This skill is exclusively for Zod 4, which introduced breaking changes from Zod 3. All examples and recommendations use Zod 4 syntax.
Installation
npm install zod@^4.0.0
Critical Zod 4 Changes
If you encounter Zod 3 code or examples, be aware of these breaking changes:
Error Customization - Use error not message
z.string().min(5, { error: 'Too short.' });
z.string().min(5, { message: 'Too short.' });
String Formats - Use top-level functions
z.email();
z.uuid();
z.url();
z.iso.date();
z.string().email();
Object Methods - Use dedicated functions
z.strictObject({ name: z.string() });
z.looseObject({ name: z.string() });
z.object({ name: z.string() }).strict();
z.object({ name: z.string() }).passthrough();
Error Formatting - Use top-level functions
z.flattenError(error);
z.treeifyError(error);
z.prettifyError(error);
error.flatten();
error.format();
Function Schemas - New syntax
const myFn = z.function({
input: [z.string()],
output: z.number(),
});
const myFn = z.function().args(z.string()).returns(z.number());
Enums - Unified API
enum Color {
Red = 'red',
Green = 'green',
}
z.enum(Color);
z.nativeEnum(Color);
Deprecated APIs to avoid:
invalid_type_errorandrequired_errorparameters (useerrorfunction instead).merge()on objects (use.extend()or object spread).deepPartial()(removed, anti-pattern)z.promise()(rarely needed, just await the promise)- Single-argument
z.record()(now requires both key and value schemas)
Key Improvements in Zod 4
- Performance: Dramatically faster parsing and validation
- Error handling: Unified
errorparameter for all error customization - Type safety: Better TypeScript inference and type narrowing
- Tree-shaking: Top-level functions are more tree-shakable
- Refinements: Now stored inside schemas, not wrapper classes
- Defaults in optional fields: Applied correctly within optional properties
Core Principles
- Follow coding guidelines strictly (SRP, single export per file, prefer interfaces over types)
- Create properly structured schema files with kebab-case naming
- Leverage Zod's type inference for compile-time type safety
- Implement proper error handling with safeParse for user-facing validations
- Use refinements for custom validation logic
- Organize schemas in a dedicated schemas directory structure
Common Use Cases
1. API Request/Response Validation
When implementing API validation:
- Create schema files in
src/schemas/or appropriate directory - Export a single schema per file using kebab-case naming
- Use
.safeParse()for user input to handle errors gracefully - Use
.parse()only when input is guaranteed to be valid - Infer TypeScript types from schemas using
z.infer<>
Example structure:
src/
schemas/
user-create-request.schema.ts
user-response.schema.ts
interfaces/
user.interface.ts
functions/
validate-user-request.ts
2. Form Validation
For form validation:
- Create schemas matching form structure
- Use
.safeParse()to validate on submit - Extract and display field-specific errors using
z.flattenError() - Implement real-time validation with debouncing if needed
- Use refinements for cross-field validation (e.g., password confirmation)
3. Environment Variable Validation
For environment validation:
- Create a schema in
src/schemas/environment.schema.ts - Validate on application startup using
.parse() - Let it throw if environment is invalid (fail-fast approach)
- Export inferred type for use throughout the application
4. Schema Organization
Follow this structure:
- Simple schemas: Direct export from schema file
- Complex schemas: Build from smaller schemas
- Shared schemas: Create base schemas and extend/pick as needed
- Keep one schema export per file (following SRP)
5. Type Inference and Interfaces
- Use
z.infer<typeof Schema>to extract types - Save inferred types as interfaces in the interfaces directory
- Use interfaces instead of inline types throughout the codebase
- Export one interface per file with kebab-case naming
6. Error Handling Patterns
Implement these patterns:
- User input: Use
.safeParse()and handle errors gracefully - Internal validation: Use
.parse()to fail fast on programming errors - Async validation: Use
.parseAsync()or.safeParseAsync()with async validations - Custom errors: Use the
errorparameter for user-friendly messages - Formatted errors: Use
z.flattenError()for forms,z.treeifyError()for nested data
7. Understanding Error Structure
When validation fails, Zod returns a ZodError instance containing an .issues array. Each issue provides granular information about what went wrong.
The Issues Array
Every validation error contains detailed metadata:
const result = schema.safeParse(invalidData);
if (!result.success) {
result.error.issues;
}
Each issue object contains:
-
code: Error code indicating the type of validation failure
invalid_type: Wrong data type (e.g., expected string, got number)custom: Custom validation from.refine()or.superRefine()too_big: Value exceeds maximum constrainttoo_small: Value below minimum constraintunrecognized_keys: Extra keys in strict objectsinvalid_string: String format validation failed (email, url, etc.)- And many more specific codes
-
path: Array showing the location of the error in nested structures
[]for top-level errors['username']for object property errors['users', 0, 'email']for nested array/object errors
-
message: Human-readable error description
-
Context-specific properties depending on error type:
expectedandreceivedfor type mismatchesminimumandmaximumfor size constraintsinclusivefor whether constraints are inclusivekeysfor unrecognized keysvalidationfor string format types
Working with Issues Directly
Access the raw issues array for maximum control:
const result = UserSchema.safeParse(data);
if (!result.success) {
result.error.issues.forEach((issue) => {
console.log(`Error at ${issue.path.join('.')}: ${issue.message}`);
console.log(`Error code: ${issue.code}`);
if (issue.code === 'invalid_type') {
console.log(`Expected ${issue.expected}, got ${issue.received}`);
}
});
}
Error Formatting Utilities
Instead of manually processing issues, use Zod's formatting utilities:
For flat forms (single level):
const flattened = z.flattenError(result.error);
flattened.formErrors;
flattened.fieldErrors.username;
flattened.fieldErrors.email;
For nested structures:
const tree = z.treeifyError(result.error);
tree.errors;
tree.properties?.username?.errors;
tree.properties?.favoriteNumbers?.items?.[1]?.errors;
For debugging:
const pretty = z.prettifyError(result.error);
console.log(pretty);
Common Error Response Patterns
API Response with Issues:
export const handleValidationError = (error: z.ZodError) => {
return {
success: false,
errors: error.issues.map((issue) => ({
field: issue.path.join('.'),
message: issue.message,
code: issue.code,
})),
};
};
Form Field Errors:
export const getFieldErrors = (error: z.ZodError) => {
const formatted = z.flattenError(error);
return {
formErrors: formatted.formErrors,
fieldErrors: formatted.fieldErrors,
};
};
Detailed Error Logging:
export const logValidationError = (error: z.ZodError, context: string) => {
error.issues.forEach((issue) => {
logger.error({
context,
field: issue.path.join('.'),
code: issue.code,
message: issue.message,
input: issue.input,
});
});
};
8. Advanced Validation with Refinements
Use refinements when:
- Custom business logic validation is needed
- Cross-field validation is required
- Async validation is necessary (database lookups, API calls)
- Complex conditional validation logic applies
9. Common Schema Patterns
Implement these patterns:
- Optional with default: Use
.optional().default(value)for optional fields (note: in Zod 4, defaults are applied even within optional fields) - Nullable vs optional: Use
.nullable()for null values,.optional()for undefined - String formats: Use top-level
z.email(),z.uuid(),z.url(),z.iso.datetime(), etc. (not method-based) - Number constraints: Use
z.int(),z.int32(),z.uint32(),z.float32(),z.float64(),.min(),.max() - Arrays: Use
.min(),.max(),.nonempty()for array validation (note:.nonempty()now infers asstring[]not[string, ...string[]]) - Tuples with rest: Use
z.tuple([z.string()], z.string())for[string, ...string[]]pattern - Transformations: Use
.transform()to convert data types (returnsZodPipein Zod 4) - Preprocessing: Use
.preprocess()to normalize input before validation (returnsZodPipein Zod 4) - Strict objects: Use
z.strictObject()instead ofz.object().strict() - Loose objects: Use
z.looseObject()instead ofz.object().passthrough() - Records with enums: Use
z.record()for exhaustive records orz.partialRecord()for optional keys
Implementation Guidelines
Check Zod Version First
IMPORTANT: Before implementing any Zod validation, check the project's Zod version.
- Check
package.jsonfor the Zod version - If the project uses Zod v3 (any version
<4.0.0):- Inform the user that the project is using Zod v3
- Ask if they would like to upgrade to Zod v4
- If yes:
- Upgrade to Zod v4:
npm install zod@^4.0.0 - Follow the migration guidelines in the "Migration from Zod 3" section below
- Update all existing Zod v3 code to Zod v4 syntax before proceeding with new implementation
- Upgrade to Zod v4:
- If no:
- Inform the user that this skill is designed exclusively for Zod v4 and cannot provide accurate guidance for Zod v3
- Abort the skill and suggest they use general TypeScript assistance or find Zod v3-specific resources
- If the project already uses Zod v4, proceed with implementation
When Asked to Implement Zod Validation
Critical: Use Zod 4 Syntax
- Use
errorparameter (notmessage,invalid_type_error, orerrorMap) - Use top-level string validators:
z.email()(notz.string().email()) - Use
z.strictObject()andz.looseObject()(not.strict()or.passthrough()) - Use
z.flattenError()andz.treeifyError()(not.flatten()or.format()) - Use unified
z.enum()for both string unions and native enums - Use two-argument
z.record(key, value)(not single argument)
Implementation Steps
-
Assess the validation target: Request validation, response validation, form data, environment variables, etc.
-
Create the schema file:
- Place in appropriate schemas directory
- Use kebab-case naming (e.g.,
user-login-request.schema.ts) - Import Zod as
import * as z from "zod" - Export a single schema constant
- Use only Zod 4 syntax
-
Generate the interface file:
- Place in interfaces directory
- Use
z.infer<typeof Schema>to extract type - Export as an interface (not a type)
- One interface per file
-
Create validation function (if needed):
- Place in functions directory
- Implement proper error handling
- Use async/await syntax for async validation
- Return structured validation results
-
Implement error handling:
- Use
.safeParse()for user input - Check
result.successbefore accessing data - Format errors appropriately for the use case
- Provide helpful error messages
- Use
-
Add custom validation (if needed):
- Use
.refine()for simple custom checks - Use
.superRefine()for multiple custom validations - Add
abort: truefor critical validations - Use the
whenparameter to control refinement execution
- Use
-
Handle async validation:
- Use
.refine()with async function - Use
.parseAsync()or.safeParseAsync() - Implement proper error handling with try/catch
- Use async/await syntax (not promises)
- Use
Code Quality Standards
- No comments in new code (make names descriptive)
- Use aliased imports from tsconfig
- Avoid using
require(), use ES6 imports - Use async/await instead of .then() syntax
- One export per file
- Follow existing patterns in the codebase
Example Implementations
Schema File
import * as z from 'zod';
export const UserCreateRequestSchema = z.object({
username: z.string().min(3).max(20),
email: z.email(),
password: z.string().min(8),
age: z.int().min(18).optional(),
});
Interface File
import * as z from 'zod';
import { UserCreateRequestSchema } from '@/schemas/user-create-request.schema';
export interface UserCreateRequest extends z.infer<typeof UserCreateRequestSchema> {}
Validation Function
import * as z from 'zod';
import { UserCreateRequestSchema } from '@/schemas/user-create-request.schema';
export const validateUserCreateRequest = (data: unknown) => {
const result = UserCreateRequestSchema.safeParse(data);
if (!result.success) {
return {
success: false,
errors: z.flattenError(result.error).fieldErrors,
};
}
return {
success: true,
data: result.data,
};
};
With Refinement
import * as z from 'zod';
export const PasswordConfirmSchema = z
.object({
password: z.string().min(8),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
error: 'Passwords do not match',
path: ['confirmPassword'],
});
Async validation
import * as z from 'zod';
export const UsernameSchema = z.string().refine(
async (username) => {
const exists = await checkUsernameExists(username);
return !exists;
},
{
error: 'Username already taken',
},
);
Working with Error Issues
import * as z from 'zod';
import { UserCreateRequestSchema } from '@/schemas/user-create-request.schema';
export const validateAndLogErrors = (data: unknown) => {
const result = UserCreateRequestSchema.safeParse(data);
if (!result.success) {
result.error.issues.forEach((issue) => {
const field = issue.path.join('.');
if (issue.code === 'invalid_type') {
console.error(`Type error at ${field}: expected ${issue.expected}, got ${issue.received}`);
} else if (issue.code === 'too_small') {
console.error(`Validation error at ${field}: ${issue.message} (minimum: ${issue.minimum})`);
} else {
console.error(`Error at ${field}: ${issue.message}`);
}
});
return {
success: false,
errors: result.error.issues.map((issue) => ({
field: issue.path.join('.'),
message: issue.message,
code: issue.code,
})),
};
}
return {
success: true,
data: result.data,
};
};
Error Response for API
import * as z from 'zod';
export const createApiErrorResponse = (error: z.ZodError) => {
const flattened = z.flattenError(error);
return {
success: false,
message: 'Validation failed',
errors: {
general: flattened.formErrors,
fields: Object.entries(flattened.fieldErrors).map(([field, messages]) => ({
field,
messages,
})),
},
issues: error.issues,
};
};
Migration from Zod 3
If migrating existing Zod 3 code, follow this systematic approach:
Step 1: Update Error Customization
Replace message with error:
z.string().min(5, { message: 'Too short.' });
z.string().min(5, { error: 'Too short.' });
Replace invalid_type_error and required_error:
z.string({
required_error: 'Required',
invalid_type_error: 'Not a string',
});
z.string({
error: (iss) => (iss.input === undefined ? 'Required' : 'Not a string'),
});
Replace errorMap with error:
z.string({
errorMap: (issue, ctx) => ({
message: issue.code === 'too_small' ? `Too small` : ctx.defaultError,
}),
});
z.string({
error: (iss) => {
if (iss.code === 'too_small') return 'Too small';
return undefined;
},
});
Step 2: Update String Format Validators
z.string().email();
z.string().uuid();
z.string().url();
z.string().datetime();
z.email();
z.uuid();
z.url();
z.iso.datetime();
Step 3: Update Object Schemas
Replace .strict() and .passthrough():
z.object({ name: z.string() }).strict();
z.object({ name: z.string() }).passthrough();
z.strictObject({ name: z.string() });
z.looseObject({ name: z.string() });
Replace .merge() with .extend():
BaseSchema.merge(ExtensionSchema);
BaseSchema.extend(ExtensionSchema.shape);
z.object({
...BaseSchema.shape,
...ExtensionSchema.shape,
});
Step 4: Update Enums
enum Color {
Red = 'red',
Green = 'green',
}
z.nativeEnum(Color);
z.enum(Color);
Step 5: Update Error Formatting
error.flatten();
error.format();
z.flattenError(error);
z.treeifyError(error);
Step 6: Update Function Schemas
z.function().args(z.string(), z.number()).returns(z.boolean());
z.function({
input: [z.string(), z.number()],
output: z.boolean(),
});
Step 7: Update Record Schemas
z.record(z.string());
z.record(z.string(), z.string());
Step 8: Review Defaults in Optional Fields
Be aware that defaults are now applied within optional fields:
const schema = z.object({
name: z.string().default('Unknown').optional(),
});
schema.parse({});
Step 9: Update Number Validations
- Remove
.safe()usage (now same as.int()) - Replace with
z.int()for safe integers - Be aware POSITIVE_INFINITY and NEGATIVE_INFINITY are no longer valid
Step 10: Update Issue Type References
z.ZodInvalidTypeIssue;
z.ZodTooBigIssue;
z.core.$ZodIssueInvalidType;
z.core.$ZodIssueTooBig;
Common Migration Errors to Watch For
- Using deprecated
.messageparameter - Replace witherror - Using method-based string validators - Use top-level functions
- Using
.merge()on objects - Use.extend()or spread - Using single-arg
z.record()- Provide both key and value schemas - Using
z.nativeEnum()- Use unifiedz.enum() - Calling
.flatten()on errors - Usez.flattenError() - Using
invalid_type_error- Useerrorfunction parameter - Using
.deepPartial()- Remove, no replacement (anti-pattern)
Codemod Available
A community-maintained codemod is available: zod-v3-to-v4
npx zod-v3-to-v4
Note: Review all automated changes carefully as the codemod may not catch all edge cases.
Response Format
When helping with Zod validation:
- Acknowledge the validation requirement
- Verify Zod version - Use Zod 4 syntax and APIs
- Determine the appropriate schema structure
- Create the schema file following guidelines
- Create corresponding interface file
- Create validation function if needed
- Implement error handling with Zod 4 APIs
- Add any custom refinements required
- Use top-level functions (not deprecated methods)
- Test the implementation
Prioritize type safety, clear error messages, Zod 4 best practices, and adherence to the user's coding standards.
Quick Reference: Zod 3 vs Zod 4
Error Customization
| Zod 3 | Zod 4 |
|---|---|
z.string().min(5, { message: "..." }) |
z.string().min(5, { error: "..." }) |
z.string({ required_error: "...", invalid_type_error: "..." }) |
z.string({ error: (iss) => iss.input === undefined ? "..." : "..." }) |
z.string({ errorMap: (iss, ctx) => ({ message: "..." }) }) |
z.string({ error: (iss) => "..." }) |
String Validators
| Zod 3 | Zod 4 |
|---|---|
z.string().email() |
z.email() |
z.string().uuid() |
z.uuid() |
z.string().url() |
z.url() |
z.string().datetime() |
z.iso.datetime() |
z.string().date() |
z.iso.date() |
z.string().time() |
z.iso.time() |
z.string().duration() |
z.iso.duration() |
z.string().ip() |
z.ipv4() or z.ipv6() |
z.string().cidr() |
z.cidrv4() or z.cidrv6() |
Object Schemas
| Zod 3 | Zod 4 |
|---|---|
z.object({ ... }).strict() |
z.strictObject({ ... }) |
z.object({ ... }).passthrough() |
z.looseObject({ ... }) |
Base.merge(Extension) |
Base.extend(Extension.shape) or z.object({ ...Base.shape, ...Extension.shape }) |
z.object({ ... }).deepPartial() |
Removed (no replacement) |
Enums
| Zod 3 | Zod 4 |
|---|---|
z.nativeEnum(MyEnum) |
z.enum(MyEnum) |
Schema.Enum.Value |
Removed |
Schema.Values.Value |
Removed |
Schema.enum.Value |
Schema.enum.Value (unchanged) |
Error Formatting
| Zod 3 | Zod 4 |
|---|---|
error.flatten() |
z.flattenError(error) |
error.format() |
z.treeifyError(error) |
| N/A | z.prettifyError(error) (new) |
Function Schemas
| Zod 3 | Zod 4 |
|---|---|
z.function().args(z.string()).returns(z.number()) |
z.function({ input: [z.string()], output: z.number() }) |
myFn.implement((arg) => ...) |
myFn.implement((arg) => ...) (unchanged) |
| N/A | myFn.implementAsync(async (arg) => ...) (new) |
Record Schemas
| Zod 3 | Zod 4 |
|---|---|
z.record(z.string()) |
z.record(z.string(), z.string()) (requires both args) |
z.record(z.enum(["a", "b"]), z.number()) returns { a?: number; b?: number } |
Returns { a: number; b: number } (exhaustive) |
| N/A | z.partialRecord(z.enum(["a", "b"]), z.number()) for optional keys |
Number Validators
| Zod 3 | Zod 4 |
|---|---|
z.number().safe() |
z.int() (same behavior) |
z.number().int() (accepts unsafe ints) |
z.number().int() (safe integers only) |
z.number() accepts Infinity |
z.number() rejects Infinity |
| N/A | z.int32(), z.uint32(), z.float32(), z.float64() (new) |
Array Validators
| Zod 3 | Zod 4 |
|---|---|
z.array(z.string()).nonempty() infers as [string, ...string[]] |
Infers as string[] |
| N/A | z.tuple([z.string()], z.string()) for [string, ...string[]] |
Issue Types
| Zod 3 | Zod 4 |
|---|---|
z.ZodInvalidTypeIssue |
z.core.$ZodIssueInvalidType |
z.ZodTooBigIssue |
z.core.$ZodIssueTooBig |
z.ZodTooSmallIssue |
z.core.$ZodIssueTooSmall |
z.ZodInvalidStringIssue |
z.core.$ZodIssueInvalidStringFormat |
z.ZodCustomIssue |
z.core.$ZodIssueCustom |
z.ZodInvalidEnumValueIssue |
z.core.$ZodIssueInvalidValue |
z.ZodInvalidLiteralIssue |
z.core.$ZodIssueInvalidValue |
Miscellaneous
| Zod 3 | Zod 4 |
|---|---|
z.promise(z.string()) |
Just await the promise |
.default() applies to input type |
.default() applies to output type |
| N/A | .prefault() for pre-parse default (Zod 3 behavior) |
| Defaults not applied in optional fields | Defaults applied even in optional fields |