Install
npx skillscat add thecarlo/carlo-marketplace/zod Install via the SkillsCat registry.
This skill provides guidance for implementing type-safe validation in TypeScript applications using Zod v4. It addresses schema design, error handling, type inference, and migration from Zod 3, including documented breaking changes and deprecated APIs. Developers should use it when building or maintaining codebases that rely on Zod, particularly during Zod 3 to Zod 4 upgrades.
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.0Critical 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.ts2. 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-v4Note: 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 |