dist/plugins/web-forms-zod-validation/skills/web-forms-zod-validation/SKILL.md
Zod schema validation patterns for TypeScript - schema definitions, type inference, refinements, transforms, discriminated unions
npx skillsauth add agents-inc/skills web-forms-zod-validationInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Quick Guide: Use Zod for runtime validation at trust boundaries (API responses, form inputs, config, URL params). Define schemas once, derive types with
z.infer. UsesafeParsefor error handling,refine/superRefinefor custom validation,transformfor data conversion. Named constants for all validation limits.Version Note: Zod v4 is now the stable release (v4.1+). It brings 14.7x faster string parsing, 57% smaller bundle, and new top-level APIs (
z.email(),z.url(),z.iso.*). The v3 method-chain equivalents (z.string().email()) still work but are deprecated. For migration details, see reference.md.
<critical_requirements>
All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering,
import type, named constants)
(You MUST use safeParse instead of parse for user-facing validation - prevents unhandled exceptions)
(You MUST use z.infer<typeof schema> to derive types - never duplicate schema as separate interface)
(You MUST validate at trust boundaries - API responses, form inputs, config files, URL params)
(You MUST use named constants for validation limits - NO magic numbers in .min(), .max(), .length())
</critical_requirements>
Auto-detection: Zod schemas, z.object, z.string, z.number, z.infer, safeParse, refine, superRefine, transform, discriminatedUnion, z.coerce, z.pipe, z.catch, z.brand, z.lazy, z.email, z.url, z.iso
When to use:
When NOT to use:
TypeScript provides compile-time type safety for code you control. Zod provides runtime validation for data you don't control - API responses, user input, configuration files, URL parameters. Use TypeScript for internal contracts; use Zod at trust boundaries where external data enters your system.
Key principle: Define the schema once, derive the type. Never maintain parallel type definitions and validation logic - they will drift apart.
// Schema is the source of truth
const UserSchema = z.object({
name: z.string(),
email: z.string().email(),
});
// Type is derived, always in sync
type User = z.infer<typeof UserSchema>;
</philosophy>
Define schemas with named constants for all validation limits. Custom error messages for user-facing fields.
const MIN_USERNAME_LENGTH = 3;
const MAX_USERNAME_LENGTH = 50;
const UserSchema = z.object({
username: z
.string()
.min(
MIN_USERNAME_LENGTH,
`Username must be at least ${MIN_USERNAME_LENGTH} characters`,
)
.max(
MAX_USERNAME_LENGTH,
`Username cannot exceed ${MAX_USERNAME_LENGTH} characters`,
),
email: z.string().email("Invalid email format"),
});
type User = z.infer<typeof UserSchema>; // Always derived, never manual interface
Why good: named constants make limits discoverable, custom error messages improve UX, type derived from schema
See examples/core.md for complete schema examples with reusable sub-schemas and CRUD composition patterns.
Use safeParse for user input and API responses. Reserve parse for config/internal data where invalid = programming error.
const result = UserSchema.safeParse(data);
if (!result.success) {
const errors = result.error.issues.reduce(
(acc, err) => {
const field = err.path.join(".");
acc[field] = err.message;
return acc;
},
{} as Record<string, string>,
);
return { success: false, errors };
}
return { success: true, user: result.data };
Why good: safeParse never throws, validation errors handled explicitly, error formatting provides useful field-level feedback
See examples/core.md for form validation and API response validation patterns.
Use refine for custom validation logic. Use superRefine when you need cross-field validation with specific error paths.
const MIN_PASSWORD_LENGTH = 8;
const PasswordFormSchema = z
.object({
password: z.string().min(MIN_PASSWORD_LENGTH),
confirmPassword: z.string(),
})
.superRefine((data, ctx) => {
if (data.password !== data.confirmPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Passwords do not match",
path: ["confirmPassword"],
});
}
});
Why good: superRefine enables cross-field validation with specific error paths, keeps all validation in the schema
See examples/core.md for password refinement chains and conditional validation patterns.
Use transform to convert data during validation. Use z.input and z.output when transforms change the type.
const DateSchema = z
.string()
.datetime()
.transform((str) => new Date(str));
type DateInput = z.input<typeof DateSchema>; // string
type DateOutput = z.output<typeof DateSchema>; // Date
Gotcha: z.infer returns the output type. When a function accepts pre-validation input, use z.input for the parameter type.
See examples/transforms.md for coercion patterns (URL params, form data) and transform pipelines.
Use discriminatedUnion when objects share a common discriminator field. Provides better error messages and TypeScript narrowing than union.
const NotificationSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("email"),
email: z.string().email(),
subject: z.string(),
}),
z.object({ type: z.literal("sms"), phone: z.string(), message: z.string() }),
z.object({
type: z.literal("push"),
deviceId: z.string(),
title: z.string(),
}),
]);
Why good over z.union: discriminatedUnion reports which variant failed (not "Invalid input"), TypeScript narrows type in switch statements
See examples/core.md for payment method union and type narrowing examples.
Compose schemas using extend, pick, omit, and partial for CRUD operations.
const BaseEntitySchema = z.object({
id: z.string().uuid(),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
});
const UserSchema = BaseEntitySchema.extend({
email: z.string().email(),
name: z.string(),
});
const CreateUserSchema = UserSchema.omit({
id: true,
createdAt: true,
updatedAt: true,
});
const UpdateUserSchema = CreateUserSchema.partial();
const UserSummarySchema = UserSchema.pick({ id: true, name: true });
See examples/core.md for full CRUD schema composition example.
Use z.coerce for URL params and form data that arrive as strings. Simpler than manual parsing.
const DEFAULT_PAGE = 1;
const DEFAULT_LIMIT = 20;
const MAX_LIMIT = 100;
const PaginationSchema = z.object({
page: z.coerce.number().int().positive().default(DEFAULT_PAGE),
limit: z.coerce
.number()
.int()
.positive()
.max(MAX_LIMIT)
.default(DEFAULT_LIMIT),
});
// "3" -> 3, "50" -> 50, missing -> defaults
Gotcha: z.coerce.boolean() coerces any truthy value to true, including the string "false". Use explicit comparison for string booleans.
See examples/transforms.md for complete pagination and query param patterns.
const ProfileSchema = z.object({
name: z.string(), // Required
bio: z.string().optional(), // string | undefined
avatar: z.string().url().nullable(), // string | null
nickname: z.string().nullish(), // string | null | undefined
theme: z.string().default("light"), // string (always defined)
});
Key distinction: nullable = explicitly set to null (API returns null), optional = may be omitted entirely, nullish = either.
Detailed Resources:
<red_flags>
High Priority Issues:
parse for user-facing validation - Throws exceptions for expected invalid input, requiring try-catch and losing detailed error info.min(3).max(50) is undocumented; use named constants like MIN_USERNAME_LENGTHz.infer<typeof schema>parse instead of parseAsync - Async refinements silently fail with sync parse methodsMedium Priority Issues:
.passthrough() by default - Allows unexpected fields through; use .strict() when you want to reject extrasGotchas & Edge Cases:
z.coerce.boolean(): Coerces any truthy value to true, including string "false" - use explicit string comparison if needed.transform() runs after all other validations; refinements on transformed values need .pipe() to validate afterz.string().email() rejects empty strings; use .email().or(z.literal("")) to allow empty.extend() on a schema with .refine() throws; apply refinements after extending insteadz.coerce.date() uses new Date() which accepts many formats; use .datetime() for strict ISO formatz.union vs z.discriminatedUnion: Union tries all schemas and reports combined errors; discriminatedUnion uses discriminator for targeted validation and better errors.refine() function second arg removed: z.string().refine(fn, (val) => ({ message: ... })) no longer works; use superRefine() for dynamic messagesctx.path removed in .superRefine(): No longer available for performance reasons; ctx.addIssue() still works.flatten() deprecated - use z.flattenError() instead; .format() deprecated - use z.treeifyError() instead; .merge() deprecated - use .extend() instead</red_flags>
<critical_reminders>
All code must follow project conventions in CLAUDE.md
(You MUST use safeParse instead of parse for user-facing validation - prevents unhandled exceptions)
(You MUST use z.infer<typeof schema> to derive types - never duplicate schema as separate interface)
(You MUST validate at trust boundaries - API responses, form inputs, config files, URL params)
(You MUST use named constants for validation limits - NO magic numbers in .min(), .max(), .length())
Failure to follow these rules will create type mismatches, unhandled exceptions, and unmaintainable validation code.
</critical_reminders>
development
Material Design component library for Vue 3
development
VitePress 1.x — Vue-powered static site generator for documentation sites, built on Vite
tools
Docusaurus 3.x documentation framework — site configuration, docs/blog plugins, sidebars, versioning, MDX, swizzling, and deployment
development
TanStack Form patterns - useForm, form.Field, validators, arrays, linked fields, createFormHook, type safety