web-forms-zod-validation · git:20260202.b236384 · 2026-02-02 · sha256 292ed933f85a1bd8
web-forms-zod-validation git:20260202.b236384A
Immutable. This exact content is served forever at /api/v1/blob/292ed933f85a1bd8.
---
name: web-forms-zod-validation
description: Zod schema validation patterns for TypeScript - schema definitions, type inference, refinements, transforms, discriminated unions
---
# Zod Schema Validation Patterns
> **Quick Guide:** Use Zod for runtime validation of untrusted data (API responses, form inputs, config). Define schemas once, infer TypeScript types with `z.infer`. Use `safeParse` for error handling, `refine` for custom validation, `transform` for data transformation.
>
> **Version Note:** This skill documents Zod v3.23 patterns. Zod v4 is available with major performance improvements (14.7x faster string parsing, 100x fewer TypeScript instantiations) and new APIs (`z.email()`, `z.iso.*`, `z.templateLiteral()`, `z.codec()`). For v4 migration, see [zod.dev/v4](https://zod.dev/v4).
---
<critical_requirements>
## CRITICAL: Before Using This Skill
> **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, transform, discriminatedUnion, z.coerce, z.pipe, z.catch, z.brand, z.lazy
**When to use:**
- Validating API responses before using data
- Parsing form input data with type safety
- Validating configuration files or environment variables
- Defining contracts between systems (frontend/backend shared schemas)
- Runtime type checking for data from untrusted sources
**When NOT to use:**
- Internal function parameters (TypeScript is sufficient for trusted data)
- Simple boolean checks that don't need schema definition
- Performance-critical hot paths where validation overhead matters
**Detailed Resources:**
- For code examples, see [examples/](examples/)
- For decision frameworks and anti-patterns, see [reference.md](reference.md)
---
<philosophy>
## Philosophy
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.
```typescript
// 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>
---
<patterns>
## Core Patterns
### Pattern 1: Schema Definition Basics
Define schemas using Zod's fluent API. Compose complex schemas from primitives.
#### Primitives and Objects
```typescript
import { z } from "zod";
// Validation limits as named constants
const MIN_USERNAME_LENGTH = 3;
const MAX_USERNAME_LENGTH = 50;
const MIN_AGE = 0;
const MAX_AGE = 150;
// ✅ Good Example - Named constants, descriptive error messages
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"),
age: z
.number()
.int("Age must be a whole number")
.min(MIN_AGE, "Age cannot be negative")
.max(MAX_AGE, `Age cannot exceed ${MAX_AGE}`),
});
// Derive type from schema
type User = z.infer<typeof UserSchema>;
```
**Why good:** named constants make limits discoverable and maintainable, custom error messages improve UX, type is derived from schema ensuring sync
```typescript
// ❌ Bad Example - Magic numbers, no error messages
const UserSchema = z.object({
username: z.string().min(3).max(50), // What are these limits?
email: z.string().email(),
age: z.number().int().min(0).max(150),
});
// Separate interface duplicates schema
interface User {
username: string;
email: string;
age: number;
}
```
**Why bad:** magic numbers have no context when reading code, parallel interface will drift from schema over time, default error messages are not user-friendly
---
### Pattern 2: Type Inference with z.infer
Use `z.infer` to extract TypeScript types from schemas. Use `z.input` and `z.output` when transforms change the type.
```typescript
import { z } from "zod";
// ✅ Good Example - Type derived from schema
const ProductSchema = z.object({
id: z.string().uuid(),
name: z.string(),
price: z.number().positive(),
createdAt: z.string().datetime(),
});
// Single source of truth - type always matches schema
type Product = z.infer<typeof ProductSchema>;
// For schemas with transforms, use input/output types
const DateSchema = z.string().transform((str) => new Date(str));
type DateInput = z.input<typeof DateSchema>; // string
type DateOutput = z.output<typeof DateSchema>; // Date
```
**Why good:** type is automatically derived ensuring compile-time and runtime validation match, input/output types handle transform cases correctly
```typescript
// ❌ Bad Example - Manually defining interface
const ProductSchema = z.object({
id: z.string().uuid(),
name: z.string(),
price: z.number().positive(),
});
// Manual interface - can drift from schema
interface Product {
id: string;
name: string;
price: number;
description?: string; // Added here but not in schema!
}
```
**Why bad:** manual interface has extra field `description` that schema doesn't validate, creating false confidence that data has been validated
---
### Pattern 3: Safe Parsing for Error Handling
Use `safeParse` for graceful error handling. Reserve `parse` for cases where invalid data is a programming error.
```typescript
import { z } from "zod";
const UserSchema = z.object({
email: z.string().email(),
name: z.string().min(1),
});
// ✅ Good Example - safeParse with discriminated union result
function validateUserInput(
data: unknown,
):
| { success: true; user: User }
| { success: false; errors: Record<string, string> } {
const result = UserSchema.safeParse(data);
if (!result.success) {
// Format errors for display
const errors = result.error.errors.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 };
}
type User = z.infer<typeof UserSchema>;
```
**Why good:** safeParse never throws so validation errors are handled explicitly, error formatting provides useful feedback, discriminated union return type is type-safe
```typescript
// ❌ Bad Example - Using parse for user input
function validateUserInput(data: unknown) {
try {
const user = UserSchema.parse(data); // Throws on invalid!
return { success: true, user };
} catch (error) {
// Generic catch loses type information
return { success: false, error: "Validation failed" };
}
}
```
**Why bad:** parse throws exceptions for expected invalid input creating noisy error handling, catch block loses detailed error information, less explicit control flow
---
### Pattern 4: Refinements for Custom Validation
Use `refine` for custom validation logic that primitives can't express. Use `superRefine` for complex cross-field validation.
```typescript
import { z } from "zod";
// ✅ Good Example - Custom refinement with clear error
const PasswordSchema = z
.string()
.min(8, "Password must be at least 8 characters")
.refine((pwd) => /[A-Z]/.test(pwd), {
message: "Password must contain at least one uppercase letter",
})
.refine((pwd) => /[0-9]/.test(pwd), {
message: "Password must contain at least one number",
})
.refine((pwd) => /[!@#$%^&*]/.test(pwd), {
message: "Password must contain at least one special character (!@#$%^&*)",
});
// Cross-field validation with superRefine
const PasswordFormSchema = z
.object({
password: z.string().min(8),
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:** refine allows arbitrary validation logic, custom messages explain exact requirement, superRefine enables cross-field validation with specific error paths
```typescript
// ❌ Bad Example - Validation outside schema
const PasswordSchema = z.string().min(8);
function validatePassword(pwd: string): string[] {
const errors = [];
if (!/[A-Z]/.test(pwd)) errors.push("Need uppercase");
if (!/[0-9]/.test(pwd)) errors.push("Need number");
return errors;
}
// Now validation is split between schema and function
```
**Why bad:** validation logic is split making it easy to forget one check, errors from function aren't integrated with Zod's error system, harder to compose with other schemas
---
### Pattern 5: Transforms for Data Conversion
Use `transform` to convert data during validation. Input type differs from output type.
```typescript
import { z } from "zod";
// ✅ Good Example - Transform string to Date
const EventSchema = z.object({
name: z.string(),
date: z
.string()
.datetime()
.transform((str) => new Date(str)),
attendees: z.string().transform((str) => parseInt(str, 10)),
});
// Input: { name: string, date: string, attendees: string }
// Output: { name: string, date: Date, attendees: number }
type EventInput = z.input<typeof EventSchema>;
type Event = z.output<typeof EventSchema>;
// Validate then transform
const result = EventSchema.safeParse({
name: "Conference",
date: "2025-06-15T09:00:00Z",
attendees: "150",
});
if (result.success) {
result.data.date.getFullYear(); // Date methods available
}
```
**Why good:** transform happens after validation ensuring valid input, separate input/output types provide correct typing, common use case for API responses where numbers arrive as strings
---
### Pattern 6: Discriminated Unions for Type Narrowing
Use `discriminatedUnion` when validating objects that share a common discriminator field. Provides better error messages than `union`.
```typescript
import { z } from "zod";
// ✅ Good Example - Discriminated union with clear discriminator
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(),
}),
]);
type Notification = z.infer<typeof NotificationSchema>;
// Type narrowing works correctly
function sendNotification(notification: Notification) {
switch (notification.type) {
case "email":
// notification.email is available (string)
break;
case "sms":
// notification.phone is available (string)
break;
case "push":
// notification.deviceId is available (string)
break;
}
}
```
**Why good:** discriminatedUnion uses the type field to determine which schema to validate against, provides specific error messages about the failing variant, TypeScript narrows type correctly in switch statements
```typescript
// ❌ Bad Example - Plain union without discriminator
const NotificationSchema = z.union([
z.object({ email: z.string().email(), subject: z.string() }),
z.object({ phone: z.string(), message: z.string() }),
]);
// Error messages are vague: "Invalid input"
// No type narrowing in code
```
**Why bad:** union tries all schemas and reports combined errors making debugging difficult, no clear way to narrow types in consuming code
---
### Pattern 7: Optional, Nullable, and Nullish
Understand the difference: `optional` allows undefined, `nullable` allows null, `nullish` allows both.
```typescript
import { z } from "zod";
// ✅ Good Example - Explicit null handling
const ProfileSchema = z.object({
// Required - must be present
name: z.string(),
// Optional - can be undefined or omitted entirely
bio: z.string().optional(), // string | undefined
// Nullable - must be present but can be null
avatar: z.string().url().nullable(), // string | null
// Nullish - can be undefined, null, or omitted
nickname: z.string().nullish(), // string | null | undefined
// Default - provides fallback value
theme: z.string().default("light"), // string (always defined)
});
type Profile = z.infer<typeof ProfileSchema>;
// { name: string; bio?: string; avatar: string | null; nickname?: string | null; theme: string }
```
**Why good:** explicit about what each field accepts, matches common API patterns where null means "explicitly not set" vs undefined means "not provided"
---
### Pattern 8: Coercion for Type Conversion
Use `z.coerce` to convert input types before validation. Useful for form data and URL params that arrive as strings.
```typescript
import { z } from "zod";
// ✅ Good Example - Coerce string inputs to proper types
const PaginationSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(20),
includeDeleted: z.coerce.boolean().default(false),
});
// Works with string inputs from query params
const result = PaginationSchema.parse({
page: "3", // Coerced to 3
limit: "50", // Coerced to 50
includeDeleted: "true", // Coerced to true
});
// result.page is number, not string
```
**Why good:** coerce handles common string-to-type conversions automatically, works with query strings and form data, default values provide sensible fallbacks
```typescript
// ❌ Bad Example - Manual parsing before validation
const page = parseInt(queryParams.page, 10);
const limit = parseInt(queryParams.limit, 10);
// Validation is separate from parsing
if (isNaN(page) || page < 1) {
// Handle error
}
```
**Why bad:** parsing and validation are split across multiple statements, error handling is manual and verbose, easy to forget edge cases like NaN
---
### Pattern 9: Schema Composition and Extension
Compose schemas using `extend`, `merge`, `pick`, `omit`, and `partial` for reusability.
```typescript
import { z } from "zod";
// Base schema
const BaseEntitySchema = z.object({
id: z.string().uuid(),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
});
// ✅ Good Example - Extend base schema
const UserSchema = BaseEntitySchema.extend({
email: z.string().email(),
name: z.string(),
});
// Pick specific fields for API response
const UserSummarySchema = UserSchema.pick({
id: true,
name: true,
});
// Omit sensitive fields
const PublicUserSchema = UserSchema.omit({
email: true,
});
// Partial for updates (all fields optional)
const UpdateUserSchema = UserSchema.partial().omit({
id: true,
createdAt: true,
updatedAt: true,
});
// Merge two schemas
const UserWithPrefsSchema = UserSchema.merge(
z.object({
preferences: z.object({
theme: z.string(),
language: z.string(),
}),
}),
);
```
**Why good:** DRY schemas avoid duplication, pick/omit create focused schemas for specific use cases, partial enables PATCH-style updates
---
### Pattern 10: Async Validation
Use `refine` with async functions for validations that require network calls. Use `safeParseAsync`.
```typescript
import { z } from "zod";
// ✅ Good Example - Async refinement for uniqueness check
const RegistrationSchema = z.object({
email: z.string().email(),
username: z
.string()
.min(3)
.refine(
async (username) => {
// Check if username is available (async operation)
const isAvailable = await checkUsernameAvailability(username);
return isAvailable;
},
{ message: "Username is already taken" },
),
});
// MUST use async parse methods
async function validateRegistration(data: unknown) {
const result = await RegistrationSchema.safeParseAsync(data);
if (!result.success) {
return { errors: result.error.flatten().fieldErrors };
}
return { user: result.data };
}
```
**Why good:** async refinement integrates network validation into schema, safeParseAsync handles async properly, validation logic is centralized
**When to use:** Database uniqueness checks, external API validation, permission checks requiring network calls
</patterns>
---
<integration>
## Integration Guide
**Zod is a standalone validation library.** It validates data and infers types. How you use the validated data depends on your form library, API layer, or database.
**Integration points:**
- Form validation: Schemas integrate via resolver patterns (see form library skill)
- API responses: Use schemas to validate and type response data
- Configuration: Validate environment variables and config files
- URL parameters: Use coercion for query string parsing
**Zod handles validation only.** For form state, API calls, or database operations, use the appropriate skill.
</integration>
---
<critical_reminders>
## 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>