A Zod v4 validation specialist.
/plugin marketplace add thecarlo/carlo-marketplace/plugin install thecarlo-commit-message@thecarlo/carlo-marketplaceThis skill inherits all available tools. When active, it can use any tool Claude has access to.
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.
This skill is exclusively for Zod 4, which introduced breaking changes from Zod 3. All examples and recommendations use Zod 4 syntax.
npm install zod@^4.0.0
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_error and required_error parameters (use error function instead).merge() on objects (use .extend() or object spread).deepPartial() (removed, anti-pattern)z.promise() (rarely needed, just await the promise)z.record() (now requires both key and value schemas)error parameter for all error customizationWhen implementing API validation:
src/schemas/ or appropriate directory.safeParse() for user input to handle errors gracefully.parse() only when input is guaranteed to be validz.infer<>Example structure:
src/
schemas/
user-create-request.schema.ts
user-response.schema.ts
interfaces/
user.interface.ts
functions/
validate-user-request.ts
For form validation:
.safeParse() to validate on submitz.flattenError()For environment validation:
src/schemas/environment.schema.ts.parse()Follow this structure:
z.infer<typeof Schema> to extract typesImplement these patterns:
.safeParse() and handle errors gracefully.parse() to fail fast on programming errors.parseAsync() or .safeParseAsync() with async validationserror parameter for user-friendly messagesz.flattenError() for forms, z.treeifyError() for nested dataWhen validation fails, Zod returns a ZodError instance containing an .issues array. Each issue provides granular information about what went wrong.
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.)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 errorsmessage: Human-readable error description
Context-specific properties depending on error type:
expected and received for type mismatchesminimum and maximum for size constraintsinclusive for whether constraints are inclusivekeys for unrecognized keysvalidation for string format typesAccess 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}`);
}
});
}
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);
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,
});
});
};
Use refinements when:
Implement these patterns:
.optional().default(value) for optional fields (note: in Zod 4, defaults are applied even within optional fields).nullable() for null values, .optional() for undefinedz.email(), z.uuid(), z.url(), z.iso.datetime(), etc. (not method-based)z.int(), z.int32(), z.uint32(), z.float32(), z.float64(), .min(), .max().min(), .max(), .nonempty() for array validation (note: .nonempty() now infers as string[] not [string, ...string[]])z.tuple([z.string()], z.string()) for [string, ...string[]] pattern.transform() to convert data types (returns ZodPipe in Zod 4).preprocess() to normalize input before validation (returns ZodPipe in Zod 4)z.strictObject() instead of z.object().strict()z.looseObject() instead of z.object().passthrough()z.record() for exhaustive records or z.partialRecord() for optional keysIMPORTANT: Before implementing any Zod validation, check the project's Zod version.
package.json for the Zod version<4.0.0):
npm install zod@^4.0.0error parameter (not message, invalid_type_error, or errorMap)z.email() (not z.string().email())z.strictObject() and z.looseObject() (not .strict() or .passthrough())z.flattenError() and z.treeifyError() (not .flatten() or .format())z.enum() for both string unions and native enumsz.record(key, value) (not single argument)Assess the validation target: Request validation, response validation, form data, environment variables, etc.
Create the schema file:
user-login-request.schema.ts)import * as z from "zod"Generate the interface file:
z.infer<typeof Schema> to extract typeCreate validation function (if needed):
Implement error handling:
.safeParse() for user inputresult.success before accessing dataAdd custom validation (if needed):
.refine() for simple custom checks.superRefine() for multiple custom validationsabort: true for critical validationswhen parameter to control refinement executionHandle async validation:
.refine() with async function.parseAsync() or .safeParseAsync()require(), use ES6 importsimport * 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(),
});
import * as z from 'zod';
import { UserCreateRequestSchema } from '@/schemas/user-create-request.schema';
export interface UserCreateRequest extends z.infer<typeof UserCreateRequestSchema> {}
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,
};
};
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'],
});
import * as z from 'zod';
export const UsernameSchema = z.string().refine(
async (username) => {
const exists = await checkUsernameExists(username);
return !exists;
},
{
error: 'Username already taken',
},
);
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,
};
};
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,
};
};
If migrating existing Zod 3 code, follow this systematic approach:
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;
},
});
z.string().email();
z.string().uuid();
z.string().url();
z.string().datetime();
z.email();
z.uuid();
z.url();
z.iso.datetime();
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,
});
enum Color {
Red = 'red',
Green = 'green',
}
z.nativeEnum(Color);
z.enum(Color);
error.flatten();
error.format();
z.flattenError(error);
z.treeifyError(error);
z.function().args(z.string(), z.number()).returns(z.boolean());
z.function({
input: [z.string(), z.number()],
output: z.boolean(),
});
z.record(z.string());
z.record(z.string(), z.string());
Be aware that defaults are now applied within optional fields:
const schema = z.object({
name: z.string().default('Unknown').optional(),
});
schema.parse({});
.safe() usage (now same as .int())z.int() for safe integersz.ZodInvalidTypeIssue;
z.ZodTooBigIssue;
z.core.$ZodIssueInvalidType;
z.core.$ZodIssueTooBig;
.message parameter - Replace with error.merge() on objects - Use .extend() or spreadz.record() - Provide both key and value schemasz.nativeEnum() - Use unified z.enum().flatten() on errors - Use z.flattenError()invalid_type_error - Use error function parameter.deepPartial() - Remove, no replacement (anti-pattern)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.
When helping with Zod validation:
Prioritize type safety, clear error messages, Zod 4 best practices, and adherence to the user's coding standards.
| 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) => "..." }) |
| 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() |
| 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) |
| 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) |
| Zod 3 | Zod 4 |
|---|---|
error.flatten() | z.flattenError(error) |
error.format() | z.treeifyError(error) |
| N/A | z.prettifyError(error) (new) |
| 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) |
| 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 |
| 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) |
| 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[]] |
| 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 |
| 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 |
This skill should be used when the user asks to "create a hookify rule", "write a hook rule", "configure hookify", "add a hookify rule", or needs guidance on hookify rule syntax and patterns.
Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, or applications. Generates creative, polished code that avoids generic AI aesthetics.