
Form Validation
- 60 installs
- 8 repo stars
- Updated August 4, 2026
- bbeierle12/skill-mcp-claude
form-validation is a Claude Code skill that provides schema-first form validation with Zod, validation timing patterns, and reusable schemas for auth, profile, address, and payment forms.
About
form-validation is a Claude Code skill for schema-first form validation using Zod as the single source of truth for both runtime validation and TypeScript types. It defines validation timing (reward early, punish late), reusable auth, profile, address, and payment schemas, and async and conditional validation patterns. A developer loads it when implementing form validation in any framework. It is the foundation the framework-specific form skills depend on.
- Schema-first validation with Zod as one source for runtime checks and types
- Reward-early-punish-late validation timing table
- Ready-made auth, profile, address, and payment schemas including Luhn check
Form Validation by the numbers
- 60 all-time installs (skills.sh)
- Ranked #1,210 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
form-validation capabilities & compatibility
- Capabilities
- form validation · schema design · react forms · form security
- Use cases
- frontend · testing
What form-validation says it does
Schema-first validation using Zod as the single source of truth for both runtime validation and TypeScript types.
This is the optimal validation timing pattern backed by UX research:
npx skills add https://github.com/bbeierle12/skill-mcp-claude --skill form-validationAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 60 |
|---|---|
| repo stars | ★ 8 |
| Last updated | August 4, 2026 |
| Repository | bbeierle12/skill-mcp-claude ↗ |
What it does
Add Zod schema-first validation to forms with correct timing and reusable auth and payment schemas.
Who is it for?
Developers adding typed, schema-first validation to forms in any framework.
Skip if: Server-side business-rule validation unrelated to form input.
When should I use this skill?
Implementing form validation for any framework.
What you get
One Zod schema drives both types and validation with reward-early-punish-late timing.
- Zod form schemas
- Validation timing configuration
- Reusable auth and payment schemas
By the numbers
- Ships auth, profile, address, and payment schema files
- Includes a Luhn algorithm card check
Files
Form Validation
Schema-first validation using Zod as the single source of truth for both runtime validation and TypeScript types.
Quick Start
import { z } from 'zod';
// 1. Define schema (validation + types in one place)
const schema = z.object({
email: z.string().min(1, 'Required').email('Invalid email'),
age: z.number().positive().optional()
});
// 2. Infer TypeScript types (never manually define)
type FormData = z.infer<typeof schema>;
// 3. Use with form library
import { zodResolver } from '@hookform/resolvers/zod';
const { register } = useForm<FormData>({
resolver: zodResolver(schema)
});Core Principle: Reward Early, Punish Late
This is the optimal validation timing pattern backed by UX research:
| Event | Show Valid (✓) | Show Invalid (✗) | Why |
|---|---|---|---|
| On input | ✅ Immediately | ❌ Never | Don't yell while typing |
| On blur | ✅ Immediately | ✅ Yes | User finished, show errors |
| During correction | ✅ Immediately | ✅ Real-time | Let them fix quickly |
Implementation
// React Hook Form
useForm({
mode: 'onBlur', // First validation on blur (punish late)
reValidateMode: 'onChange' // Re-validate on change (real-time correction)
});
// TanStack Form
useForm({
validators: {
onBlur: schema, // Validate on blur
onChange: schema // Re-validate on change (after touched)
}
});Zod Schema Patterns
Basic Types
import { z } from 'zod';
// Strings
z.string() // Any string
z.string().min(1, 'Required') // Non-empty (better than .nonempty())
z.string().email('Invalid email')
z.string().url('Invalid URL')
z.string().uuid('Invalid ID')
z.string().regex(/^\d{5}$/, 'Invalid ZIP')
// Numbers
z.number() // Any number
z.number().positive('Must be positive')
z.number().int('Must be whole number')
z.number().min(0).max(100)
// Booleans
z.boolean()
z.literal(true) // Must be exactly true
// Enums
z.enum(['admin', 'user', 'guest'])
// Arrays
z.array(z.string())
z.array(z.string()).min(1, 'Select at least one')
// Objects
z.object({
name: z.string(),
email: z.string().email()
})Common Form Schemas
// schemas/auth.ts
export const loginSchema = z.object({
email: z
.string()
.min(1, 'Please enter your email')
.email('Please enter a valid email'),
password: z
.string()
.min(1, 'Please enter your password'),
rememberMe: z.boolean().optional().default(false)
});
export const registrationSchema = z.object({
email: z
.string()
.min(1, 'Email is required')
.email('Please enter a valid email'),
password: z
.string()
.min(1, 'Password is required')
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Include at least one uppercase letter')
.regex(/[a-z]/, 'Include at least one lowercase letter')
.regex(/[0-9]/, 'Include at least one number'),
confirmPassword: z
.string()
.min(1, 'Please confirm your password')
}).refine(data => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword']
});
export const forgotPasswordSchema = z.object({
email: z
.string()
.min(1, 'Email is required')
.email('Please enter a valid email')
});
export const resetPasswordSchema = z.object({
password: z
.string()
.min(8, 'Password must be at least 8 characters'),
confirmPassword: z.string()
}).refine(data => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword']
});// schemas/profile.ts
export const profileSchema = z.object({
firstName: z.string().min(1, 'First name is required'),
lastName: z.string().min(1, 'Last name is required'),
email: z.string().email('Invalid email'),
phone: z
.string()
.regex(/^\+?[\d\s-()]+$/, 'Invalid phone number')
.optional()
.or(z.literal('')),
bio: z
.string()
.max(500, 'Bio must be 500 characters or less')
.optional()
});
export const addressSchema = z.object({
street: z.string().min(1, 'Street address is required'),
city: z.string().min(1, 'City is required'),
state: z.string().min(1, 'State is required'),
zip: z.string().regex(/^\d{5}(-\d{4})?$/, 'Invalid ZIP code'),
country: z.string().min(1, 'Country is required').default('US')
});// schemas/payment.ts
export const paymentSchema = z.object({
cardName: z.string().min(1, 'Name on card is required'),
cardNumber: z
.string()
.regex(/^\d{13,19}$/, 'Invalid card number')
.refine(val => luhnCheck(val), 'Invalid card number'),
expMonth: z
.string()
.regex(/^(0[1-9]|1[0-2])$/, 'Invalid month'),
expYear: z
.string()
.regex(/^\d{2}$/, 'Invalid year')
.refine(val => {
const year = parseInt(val, 10) + 2000;
return year >= new Date().getFullYear();
}, 'Card has expired'),
cvc: z.string().regex(/^\d{3,4}$/, 'Invalid CVC')
});
// Luhn algorithm for card validation
function luhnCheck(cardNumber: string): boolean {
let sum = 0;
let isEven = false;
for (let i = cardNumber.length - 1; i >= 0; i--) {
let digit = parseInt(cardNumber[i], 10);
if (isEven) {
digit *= 2;
if (digit > 9) digit -= 9;
}
sum += digit;
isEven = !isEven;
}
return sum % 10 === 0;
}Advanced Patterns
Conditional Validation
const orderSchema = z.object({
deliveryMethod: z.enum(['shipping', 'pickup']),
address: z.object({
street: z.string(),
city: z.string(),
zip: z.string()
}).optional()
}).refine(
data => {
if (data.deliveryMethod === 'shipping') {
return data.address?.street && data.address?.city && data.address?.zip;
}
return true;
},
{
message: 'Address is required for shipping',
path: ['address']
}
);Cross-Field Validation
const dateRangeSchema = z.object({
startDate: z.date(),
endDate: z.date()
}).refine(
data => data.endDate >= data.startDate,
{
message: 'End date must be after start date',
path: ['endDate']
}
);Schema Composition
// Base schemas
const nameSchema = z.object({
firstName: z.string().min(1),
lastName: z.string().min(1)
});
const contactSchema = z.object({
email: z.string().email(),
phone: z.string().optional()
});
// Composed schema
const userSchema = nameSchema.merge(contactSchema).extend({
role: z.enum(['admin', 'user'])
});Async Validation
For server-side checks (username availability, email uniqueness):
// With Zod refine
const usernameSchema = z
.string()
.min(3, 'Username must be at least 3 characters')
.refine(
async (username) => {
const response = await fetch(`/api/check-username?u=${encodeURIComponent(username)}`);
const { available } = await response.json();
return available;
},
{ message: 'This username is already taken' }
);
// With TanStack Form (built-in debouncing)
const form = useForm({
defaultValues: { username: '' },
validators: {
onChangeAsyncDebounceMs: 500,
onChangeAsync: async ({ value }) => {
const response = await fetch(`/api/check-username?u=${value.username}`);
const { available } = await response.json();
if (!available) {
return { fields: { username: 'Username is taken' } };
}
return undefined;
}
}
});Debounced Validation Helper
// utils/debounced-validator.ts
export function createDebouncedValidator<T>(
validator: (value: T) => Promise<string | undefined>,
delay: number = 500
) {
let timeoutId: ReturnType<typeof setTimeout>;
let latestValue: T;
return (value: T): Promise<string | undefined> => {
latestValue = value;
return new Promise((resolve) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(async () => {
// Only validate if this is still the latest value
if (value === latestValue) {
const error = await validator(value);
resolve(error);
} else {
resolve(undefined);
}
}, delay);
});
};
}
// Usage
const checkUsername = createDebouncedValidator(async (username: string) => {
const response = await fetch(`/api/check-username?u=${username}`);
const { available } = await response.json();
return available ? undefined : 'Username is taken';
}, 500);Error Messages
Principles
1. Specific: Tell users exactly what's wrong 2. Actionable: Tell users how to fix it 3. Contextual: Reference the field name 4. Friendly: Don't blame the user
Examples
// ❌ BAD: Generic, unhelpful
const badSchema = z.object({
email: z.string().email(), // "Invalid"
password: z.string().min(8), // "Too short"
phone: z.string().regex(/^\d+$/) // "Invalid"
});
// ✅ GOOD: Specific, actionable
const goodSchema = z.object({
email: z
.string()
.min(1, 'Please enter your email address')
.email('Please enter a valid email (e.g., name@example.com)'),
password: z
.string()
.min(1, 'Please create a password')
.min(8, 'Password must be at least 8 characters'),
phone: z
.string()
.regex(/^\d{10}$/, 'Please enter a 10-digit phone number')
});Message Templates
// utils/validation-messages.ts
export const messages = {
required: (field: string) => `Please enter your ${field}`,
email: 'Please enter a valid email address',
minLength: (field: string, min: number) =>
`${field} must be at least ${min} characters`,
maxLength: (field: string, max: number) =>
`${field} must be ${max} characters or less`,
pattern: (field: string, example: string) =>
`Please enter a valid ${field} (e.g., ${example})`,
match: (field: string) => `${field} fields must match`,
unique: (field: string) => `This ${field} is already in use`,
future: (field: string) => `${field} must be a future date`,
past: (field: string) => `${field} must be a past date`
};
// Usage
const schema = z.object({
email: z
.string()
.min(1, messages.required('email'))
.email(messages.email),
password: z
.string()
.min(1, messages.required('password'))
.min(8, messages.minLength('Password', 8))
});Validation Timing Utility
// utils/validation-timing.ts
export type ValidationMode = 'onBlur' | 'onChange' | 'onSubmit' | 'all';
export interface ValidationTimingConfig {
/** When to first show errors */
showErrorsOn: ValidationMode;
/** When to re-validate after first error */
revalidateOn: ValidationMode;
/** Debounce delay for onChange (ms) */
debounceMs?: number;
}
export const TIMING_PRESETS = {
/** Default: Reward early, punish late */
standard: {
showErrorsOn: 'onBlur',
revalidateOn: 'onChange'
} as ValidationTimingConfig,
/** For password strength, character counts */
realtime: {
showErrorsOn: 'onChange',
revalidateOn: 'onChange'
} as ValidationTimingConfig,
/** For simple, short forms */
submitOnly: {
showErrorsOn: 'onSubmit',
revalidateOn: 'onSubmit'
} as ValidationTimingConfig,
/** For expensive async validation */
debounced: {
showErrorsOn: 'onBlur',
revalidateOn: 'onChange',
debounceMs: 500
} as ValidationTimingConfig
} as const;
// React Hook Form mapping
export function toRHFConfig(timing: ValidationTimingConfig) {
return {
mode: timing.showErrorsOn === 'all' ? 'all' : timing.showErrorsOn,
reValidateMode: timing.revalidateOn === 'all' ? 'onChange' : timing.revalidateOn
};
}File Structure
form-validation/
├── SKILL.md
├── references/
│ ├── zod-patterns.md # Deep-dive Zod patterns
│ ├── timing-research.md # UX research on validation timing
│ └── error-message-guide.md # Writing good error messages
└── scripts/
├── schemas/
│ ├── auth.ts # Login, registration, password reset
│ ├── profile.ts # User profile, addresses
│ ├── payment.ts # Credit cards, billing
│ └── common.ts # Reusable field schemas
├── validation-timing.ts # Timing utilities
├── async-validator.ts # Debounced async validation
└── messages.ts # Error message templatesFramework Integration
| Framework | Adapter | Import |
|---|---|---|
| React Hook Form | @hookform/resolvers/zod | zodResolver(schema) |
| TanStack Form | @tanstack/zod-form-adapter | zodValidator() |
| VeeValidate | @vee-validate/zod | toTypedSchema(schema) |
| Vanilla | Direct | schema.safeParse(data) |
Reference
references/zod-patterns.md— Complete Zod API patternsreferences/timing-research.md— UX research backing timing decisionsreferences/error-message-guide.md— Writing effective error messages
{
"name": "form-validation",
"description": "Schema-first validation with Zod, timing patterns (reward early, punish late), async validation, and error message design. Use when implementing form validation for any framework.",
"tags": [
"forms",
"typescript",
"validation"
],
"sub_skills": [],
"source": "claude-user",
"type": "template",
"depends_on": [],
"enhances": [
"form-react",
"form-vue",
"form-vanilla"
],
"last_reviewed_at": "2026-06-09",
"review_score": 66,
"relevance_tier": "B"
}
/**
* Authentication Schemas
*
* Zod schemas for login, registration, password reset, and 2FA flows.
* All schemas follow the "specific, actionable error messages" principle.
*
* @module schemas/auth
*/
import { z } from 'zod';
// =============================================================================
// PASSWORD VALIDATION
// =============================================================================
/**
* Password requirements configuration
* Adjust these based on your security requirements
*/
export const PASSWORD_REQUIREMENTS = {
minLength: 8,
maxLength: 128,
requireUppercase: true,
requireLowercase: true,
requireNumber: true,
requireSpecial: false
} as const;
/**
* Base password schema with configurable requirements
*/
export const passwordSchema = z
.string()
.min(1, 'Please create a password')
.min(PASSWORD_REQUIREMENTS.minLength, `Password must be at least ${PASSWORD_REQUIREMENTS.minLength} characters`)
.max(PASSWORD_REQUIREMENTS.maxLength, `Password must be ${PASSWORD_REQUIREMENTS.maxLength} characters or less`)
.refine(
(val) => !PASSWORD_REQUIREMENTS.requireUppercase || /[A-Z]/.test(val),
'Include at least one uppercase letter'
)
.refine(
(val) => !PASSWORD_REQUIREMENTS.requireLowercase || /[a-z]/.test(val),
'Include at least one lowercase letter'
)
.refine(
(val) => !PASSWORD_REQUIREMENTS.requireNumber || /[0-9]/.test(val),
'Include at least one number'
)
.refine(
(val) => !PASSWORD_REQUIREMENTS.requireSpecial || /[!@#$%^&*(),.?":{}|<>]/.test(val),
'Include at least one special character'
);
/**
* Simple password (for login - no strength requirements)
*/
export const simplePasswordSchema = z
.string()
.min(1, 'Please enter your password');
// =============================================================================
// LOGIN
// =============================================================================
/**
* Login form schema
*
* @example
* ```tsx
* const { register } = useForm<LoginFormData>({
* resolver: zodResolver(loginSchema)
* });
* ```
*/
export const loginSchema = z.object({
email: z
.string()
.min(1, 'Please enter your email')
.email('Please enter a valid email address'),
password: simplePasswordSchema,
rememberMe: z.boolean().optional().default(false)
});
export type LoginFormData = z.infer<typeof loginSchema>;
// =============================================================================
// REGISTRATION
// =============================================================================
/**
* Registration form schema with password confirmation
*
* Uses refine for cross-field validation (password match)
*
* @example
* ```tsx
* const { register } = useForm<RegistrationFormData>({
* resolver: zodResolver(registrationSchema)
* });
* ```
*/
export const registrationSchema = z.object({
email: z
.string()
.min(1, 'Please enter your email')
.email('Please enter a valid email address'),
password: passwordSchema,
confirmPassword: z
.string()
.min(1, 'Please confirm your password'),
acceptTerms: z
.boolean()
.refine(val => val === true, 'You must accept the terms and conditions')
}).refine(
(data) => data.password === data.confirmPassword,
{
message: 'Passwords do not match',
path: ['confirmPassword']
}
);
export type RegistrationFormData = z.infer<typeof registrationSchema>;
/**
* Username-based registration (for platforms requiring usernames)
*/
export const usernameRegistrationSchema = registrationSchema.extend({
username: z
.string()
.min(3, 'Username must be at least 3 characters')
.max(20, 'Username must be 20 characters or less')
.regex(/^[a-zA-Z0-9_]+$/, 'Username can only contain letters, numbers, and underscores')
});
export type UsernameRegistrationFormData = z.infer<typeof usernameRegistrationSchema>;
// =============================================================================
// PASSWORD RESET
// =============================================================================
/**
* Forgot password (request reset)
*/
export const forgotPasswordSchema = z.object({
email: z
.string()
.min(1, 'Please enter your email')
.email('Please enter a valid email address')
});
export type ForgotPasswordFormData = z.infer<typeof forgotPasswordSchema>;
/**
* Reset password (set new password)
*/
export const resetPasswordSchema = z.object({
password: passwordSchema,
confirmPassword: z
.string()
.min(1, 'Please confirm your password')
}).refine(
(data) => data.password === data.confirmPassword,
{
message: 'Passwords do not match',
path: ['confirmPassword']
}
);
export type ResetPasswordFormData = z.infer<typeof resetPasswordSchema>;
/**
* Change password (when already logged in)
*/
export const changePasswordSchema = z.object({
currentPassword: z
.string()
.min(1, 'Please enter your current password'),
newPassword: passwordSchema,
confirmNewPassword: z
.string()
.min(1, 'Please confirm your new password')
}).refine(
(data) => data.newPassword === data.confirmNewPassword,
{
message: 'New passwords do not match',
path: ['confirmNewPassword']
}
).refine(
(data) => data.currentPassword !== data.newPassword,
{
message: 'New password must be different from current password',
path: ['newPassword']
}
);
export type ChangePasswordFormData = z.infer<typeof changePasswordSchema>;
// =============================================================================
// TWO-FACTOR AUTHENTICATION
// =============================================================================
/**
* 2FA verification code
*/
export const twoFactorSchema = z.object({
code: z
.string()
.min(1, 'Please enter the verification code')
.regex(/^\d{6}$/, 'Code must be 6 digits'),
rememberDevice: z.boolean().optional().default(false)
});
export type TwoFactorFormData = z.infer<typeof twoFactorSchema>;
/**
* Backup code (when 2FA device unavailable)
*/
export const backupCodeSchema = z.object({
code: z
.string()
.min(1, 'Please enter a backup code')
.regex(/^[a-zA-Z0-9]{8,12}$/, 'Invalid backup code format')
});
export type BackupCodeFormData = z.infer<typeof backupCodeSchema>;
// =============================================================================
// HELPERS
// =============================================================================
/**
* Password strength calculator
* Returns a score from 0-5 and a label
*/
export function calculatePasswordStrength(password: string): {
score: number;
label: 'weak' | 'fair' | 'good' | 'strong' | 'very-strong';
feedback: string[];
} {
const feedback: string[] = [];
let score = 0;
if (password.length >= 8) score++;
else feedback.push('Use at least 8 characters');
if (password.length >= 12) score++;
if (/[A-Z]/.test(password)) score++;
else feedback.push('Add uppercase letters');
if (/[a-z]/.test(password)) score++;
else feedback.push('Add lowercase letters');
if (/[0-9]/.test(password)) score++;
else feedback.push('Add numbers');
if (/[^A-Za-z0-9]/.test(password)) score++;
else feedback.push('Add special characters');
const labels = ['weak', 'weak', 'fair', 'good', 'strong', 'very-strong'] as const;
return {
score,
label: labels[Math.min(score, 5)],
feedback
};
}
/**
* Validate password strength meets minimum requirements
*/
export function meetsPasswordRequirements(password: string): boolean {
const result = passwordSchema.safeParse(password);
return result.success;
}
/**
* Payment Schemas
*
* Zod schemas for credit cards, billing addresses, and payment flows.
* Includes Luhn algorithm validation for card numbers.
*
* @module schemas/payment
*/
import { z } from 'zod';
import { usAddressSchema } from './profile';
// =============================================================================
// CARD VALIDATION UTILITIES
// =============================================================================
/**
* Luhn algorithm for credit card validation
* @param cardNumber - Card number (digits only)
* @returns true if valid
*/
export function luhnCheck(cardNumber: string): boolean {
// Remove any non-digits
const digits = cardNumber.replace(/\D/g, '');
if (digits.length < 13 || digits.length > 19) {
return false;
}
let sum = 0;
let isEven = false;
for (let i = digits.length - 1; i >= 0; i--) {
let digit = parseInt(digits[i], 10);
if (isEven) {
digit *= 2;
if (digit > 9) {
digit -= 9;
}
}
sum += digit;
isEven = !isEven;
}
return sum % 10 === 0;
}
/**
* Detect card type from number
*/
export function detectCardType(cardNumber: string): CardType | null {
const digits = cardNumber.replace(/\D/g, '');
if (/^4/.test(digits)) return 'visa';
if (/^5[1-5]/.test(digits) || /^2[2-7]/.test(digits)) return 'mastercard';
if (/^3[47]/.test(digits)) return 'amex';
if (/^6(?:011|5)/.test(digits)) return 'discover';
if (/^3(?:0[0-5]|[68])/.test(digits)) return 'diners';
if (/^35/.test(digits)) return 'jcb';
return null;
}
export type CardType = 'visa' | 'mastercard' | 'amex' | 'discover' | 'diners' | 'jcb';
/**
* Card type display info
*/
export const CARD_INFO: Record<CardType, { name: string; cvvLength: number; numberLength: number[] }> = {
visa: { name: 'Visa', cvvLength: 3, numberLength: [13, 16, 19] },
mastercard: { name: 'Mastercard', cvvLength: 3, numberLength: [16] },
amex: { name: 'American Express', cvvLength: 4, numberLength: [15] },
discover: { name: 'Discover', cvvLength: 3, numberLength: [16, 19] },
diners: { name: 'Diners Club', cvvLength: 3, numberLength: [14, 16, 19] },
jcb: { name: 'JCB', cvvLength: 3, numberLength: [16, 19] }
};
/**
* Check if expiry date is valid (not expired)
*/
export function isExpiryValid(month: number, year: number): boolean {
const now = new Date();
const currentMonth = now.getMonth() + 1;
const currentYear = now.getFullYear() % 100; // 2-digit year
if (year < currentYear) return false;
if (year === currentYear && month < currentMonth) return false;
return true;
}
/**
* Parse expiry string (MM/YY) to month and year
*/
export function parseExpiry(expiry: string): { month: number; year: number } | null {
const match = expiry.match(/^(\d{2})\/(\d{2})$/);
if (!match) return null;
const month = parseInt(match[1], 10);
const year = parseInt(match[2], 10);
if (month < 1 || month > 12) return null;
return { month, year };
}
// =============================================================================
// CREDIT CARD SCHEMAS
// =============================================================================
/**
* Card number schema with Luhn validation
*/
export const cardNumberSchema = z
.string()
.min(1, 'Card number is required')
.transform(val => val.replace(/\s/g, '')) // Remove spaces
.refine(val => /^\d{13,19}$/.test(val), 'Please enter a valid card number')
.refine(luhnCheck, 'Please enter a valid card number');
/**
* Expiry date schema (MM/YY format)
*/
export const expirySchema = z
.string()
.min(1, 'Expiry date is required')
.regex(/^\d{2}\/\d{2}$/, 'Please enter expiry as MM/YY')
.refine(val => {
const parsed = parseExpiry(val);
return parsed !== null;
}, 'Invalid expiry date')
.refine(val => {
const parsed = parseExpiry(val);
if (!parsed) return false;
return isExpiryValid(parsed.month, parsed.year);
}, 'Card has expired');
/**
* CVV/CVC schema
*/
export const cvvSchema = z
.string()
.min(1, 'Security code is required')
.regex(/^\d{3,4}$/, 'Please enter a valid security code');
/**
* Name on card schema
*/
export const cardNameSchema = z
.string()
.min(1, 'Name on card is required')
.max(50, 'Name must be 50 characters or less')
.regex(/^[a-zA-Z\s'-]+$/, 'Please enter the name as it appears on the card');
/**
* Complete credit card schema
*/
export const creditCardSchema = z.object({
cardNumber: cardNumberSchema,
cardName: cardNameSchema,
expiry: expirySchema,
cvv: cvvSchema
});
export type CreditCardFormData = z.infer<typeof creditCardSchema>;
/**
* Credit card with split expiry fields
*/
export const creditCardSplitExpirySchema = z.object({
cardNumber: cardNumberSchema,
cardName: cardNameSchema,
expMonth: z
.string()
.regex(/^(0[1-9]|1[0-2])$/, 'Invalid month'),
expYear: z
.string()
.regex(/^\d{2}$/, 'Invalid year'),
cvv: cvvSchema
}).refine(
(data) => {
const month = parseInt(data.expMonth, 10);
const year = parseInt(data.expYear, 10);
return isExpiryValid(month, year);
},
{
message: 'Card has expired',
path: ['expYear']
}
);
export type CreditCardSplitExpiryFormData = z.infer<typeof creditCardSplitExpirySchema>;
// =============================================================================
// BILLING SCHEMAS
// =============================================================================
/**
* Billing address (extends US address)
*/
export const billingAddressSchema = usAddressSchema;
export type BillingAddressFormData = z.infer<typeof billingAddressSchema>;
/**
* Complete payment form (card + billing)
*/
export const paymentFormSchema = z.object({
card: creditCardSchema,
billingAddress: billingAddressSchema,
sameAsShipping: z.boolean().default(false)
});
export type PaymentFormData = z.infer<typeof paymentFormSchema>;
/**
* Payment form with conditional billing address
*/
export const paymentWithOptionalBillingSchema = z.object({
card: creditCardSchema,
sameAsShipping: z.boolean(),
billingAddress: billingAddressSchema.optional()
}).refine(
(data) => {
if (!data.sameAsShipping && !data.billingAddress) {
return false;
}
return true;
},
{
message: 'Billing address is required',
path: ['billingAddress']
}
);
export type PaymentWithOptionalBillingFormData = z.infer<typeof paymentWithOptionalBillingSchema>;
// =============================================================================
// PAYMENT METHOD SELECTION
// =============================================================================
/**
* Payment method types
*/
export const paymentMethodSchema = z.enum([
'credit_card',
'debit_card',
'paypal',
'apple_pay',
'google_pay',
'bank_transfer'
], {
errorMap: () => ({ message: 'Please select a payment method' })
});
export type PaymentMethod = z.infer<typeof paymentMethodSchema>;
/**
* Payment method selection form
*/
export const paymentMethodSelectionSchema = z.object({
method: paymentMethodSchema,
savePaymentMethod: z.boolean().default(false)
});
export type PaymentMethodSelectionFormData = z.infer<typeof paymentMethodSelectionSchema>;
// =============================================================================
// BANK ACCOUNT (ACH)
// =============================================================================
/**
* Bank account types
*/
export const bankAccountTypeSchema = z.enum(['checking', 'savings'], {
errorMap: () => ({ message: 'Please select an account type' })
});
/**
* US Bank account (ACH)
*/
export const bankAccountSchema = z.object({
accountHolderName: z
.string()
.min(1, 'Account holder name is required'),
accountType: bankAccountTypeSchema,
routingNumber: z
.string()
.regex(/^\d{9}$/, 'Routing number must be 9 digits'),
accountNumber: z
.string()
.min(4, 'Account number is required')
.max(17, 'Account number is too long')
.regex(/^\d+$/, 'Account number must contain only digits'),
confirmAccountNumber: z
.string()
.min(1, 'Please confirm account number')
}).refine(
(data) => data.accountNumber === data.confirmAccountNumber,
{
message: 'Account numbers do not match',
path: ['confirmAccountNumber']
}
);
export type BankAccountFormData = z.infer<typeof bankAccountSchema>;
// =============================================================================
// CHECKOUT SCHEMAS
// =============================================================================
/**
* Promo/coupon code
*/
export const promoCodeSchema = z.object({
code: z
.string()
.min(1, 'Please enter a promo code')
.max(20, 'Promo code is too long')
.regex(/^[A-Z0-9]+$/, 'Promo code can only contain letters and numbers')
.transform(val => val.toUpperCase())
});
export type PromoCodeFormData = z.infer<typeof promoCodeSchema>;
/**
* Gift card
*/
export const giftCardSchema = z.object({
cardNumber: z
.string()
.min(1, 'Gift card number is required')
.regex(/^\d{16,19}$/, 'Please enter a valid gift card number'),
pin: z
.string()
.regex(/^\d{4,8}$/, 'Please enter a valid PIN')
.optional()
});
export type GiftCardFormData = z.infer<typeof giftCardSchema>;
// =============================================================================
// AMOUNT VALIDATION
// =============================================================================
/**
* Currency amount schema
*/
export const amountSchema = z
.number()
.positive('Amount must be greater than 0')
.multipleOf(0.01, 'Amount cannot have more than 2 decimal places');
/**
* Donation amount form
*/
export const donationSchema = z.object({
amount: z.union([
z.literal(10),
z.literal(25),
z.literal(50),
z.literal(100),
amountSchema
]),
isRecurring: z.boolean().default(false),
frequency: z.enum(['monthly', 'quarterly', 'yearly']).optional()
}).refine(
(data) => !data.isRecurring || data.frequency,
{
message: 'Please select a donation frequency',
path: ['frequency']
}
);
export type DonationFormData = z.infer<typeof donationSchema>;
/**
* Profile & Address Schemas
*
* Zod schemas for user profiles, addresses, and contact information.
*
* @module schemas/profile
*/
import { z } from 'zod';
// =============================================================================
// PHONE VALIDATION
// =============================================================================
/**
* Phone number patterns by region
*/
export const PHONE_PATTERNS = {
US: /^(\+1)?[\s.-]?\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}$/,
INTL: /^\+?[\d\s.-]{10,20}$/,
SIMPLE: /^[\d\s.-]+$/
} as const;
/**
* Flexible phone schema (US format preferred)
*/
export const phoneSchema = z
.string()
.regex(PHONE_PATTERNS.US, 'Please enter a valid phone number (e.g., 555-123-4567)')
.or(z.literal(''))
.optional();
/**
* International phone schema
*/
export const intlPhoneSchema = z
.string()
.regex(PHONE_PATTERNS.INTL, 'Please enter a valid phone number with country code')
.optional();
// =============================================================================
// NAME SCHEMAS
// =============================================================================
/**
* Name field schema
*/
const nameFieldSchema = z
.string()
.min(1, 'This field is required')
.max(50, 'Name must be 50 characters or less')
.regex(/^[a-zA-Z\s'-]+$/, 'Name can only contain letters, spaces, hyphens, and apostrophes');
/**
* Full name as single field
*/
export const fullNameSchema = z.object({
name: z
.string()
.min(1, 'Please enter your name')
.max(100, 'Name must be 100 characters or less')
});
export type FullNameFormData = z.infer<typeof fullNameSchema>;
/**
* Split first/last name
*/
export const splitNameSchema = z.object({
firstName: nameFieldSchema.describe('First name'),
lastName: nameFieldSchema.describe('Last name'),
middleName: z
.string()
.max(50, 'Middle name must be 50 characters or less')
.optional()
});
export type SplitNameFormData = z.infer<typeof splitNameSchema>;
// =============================================================================
// ADDRESS SCHEMAS
// =============================================================================
/**
* US ZIP code patterns
*/
export const ZIP_PATTERNS = {
US_5: /^\d{5}$/,
US_9: /^\d{5}-\d{4}$/,
US_ANY: /^\d{5}(-\d{4})?$/,
CA: /^[A-Za-z]\d[A-Za-z][ -]?\d[A-Za-z]\d$/,
UK: /^[A-Za-z]{1,2}\d[A-Za-z\d]?\s*\d[A-Za-z]{2}$/
} as const;
/**
* US States for select dropdowns
*/
export const US_STATES = [
{ value: 'AL', label: 'Alabama' },
{ value: 'AK', label: 'Alaska' },
{ value: 'AZ', label: 'Arizona' },
{ value: 'AR', label: 'Arkansas' },
{ value: 'CA', label: 'California' },
{ value: 'CO', label: 'Colorado' },
{ value: 'CT', label: 'Connecticut' },
{ value: 'DE', label: 'Delaware' },
{ value: 'FL', label: 'Florida' },
{ value: 'GA', label: 'Georgia' },
{ value: 'HI', label: 'Hawaii' },
{ value: 'ID', label: 'Idaho' },
{ value: 'IL', label: 'Illinois' },
{ value: 'IN', label: 'Indiana' },
{ value: 'IA', label: 'Iowa' },
{ value: 'KS', label: 'Kansas' },
{ value: 'KY', label: 'Kentucky' },
{ value: 'LA', label: 'Louisiana' },
{ value: 'ME', label: 'Maine' },
{ value: 'MD', label: 'Maryland' },
{ value: 'MA', label: 'Massachusetts' },
{ value: 'MI', label: 'Michigan' },
{ value: 'MN', label: 'Minnesota' },
{ value: 'MS', label: 'Mississippi' },
{ value: 'MO', label: 'Missouri' },
{ value: 'MT', label: 'Montana' },
{ value: 'NE', label: 'Nebraska' },
{ value: 'NV', label: 'Nevada' },
{ value: 'NH', label: 'New Hampshire' },
{ value: 'NJ', label: 'New Jersey' },
{ value: 'NM', label: 'New Mexico' },
{ value: 'NY', label: 'New York' },
{ value: 'NC', label: 'North Carolina' },
{ value: 'ND', label: 'North Dakota' },
{ value: 'OH', label: 'Ohio' },
{ value: 'OK', label: 'Oklahoma' },
{ value: 'OR', label: 'Oregon' },
{ value: 'PA', label: 'Pennsylvania' },
{ value: 'RI', label: 'Rhode Island' },
{ value: 'SC', label: 'South Carolina' },
{ value: 'SD', label: 'South Dakota' },
{ value: 'TN', label: 'Tennessee' },
{ value: 'TX', label: 'Texas' },
{ value: 'UT', label: 'Utah' },
{ value: 'VT', label: 'Vermont' },
{ value: 'VA', label: 'Virginia' },
{ value: 'WA', label: 'Washington' },
{ value: 'WV', label: 'West Virginia' },
{ value: 'WI', label: 'Wisconsin' },
{ value: 'WY', label: 'Wyoming' },
{ value: 'DC', label: 'District of Columbia' }
] as const;
const stateValues = US_STATES.map(s => s.value) as [string, ...string[]];
/**
* US Address schema
*/
export const usAddressSchema = z.object({
street: z
.string()
.min(1, 'Street address is required')
.max(100, 'Street address must be 100 characters or less'),
street2: z
.string()
.max(100, 'Apt/Suite must be 100 characters or less')
.optional(),
city: z
.string()
.min(1, 'City is required')
.max(50, 'City must be 50 characters or less'),
state: z
.enum(stateValues, { errorMap: () => ({ message: 'Please select a state' }) }),
zip: z
.string()
.regex(ZIP_PATTERNS.US_ANY, 'Please enter a valid ZIP code (e.g., 12345 or 12345-6789)'),
country: z.literal('US').default('US')
});
export type USAddressFormData = z.infer<typeof usAddressSchema>;
/**
* International address schema
*/
export const intlAddressSchema = z.object({
street: z
.string()
.min(1, 'Street address is required'),
street2: z.string().optional(),
city: z
.string()
.min(1, 'City is required'),
region: z
.string()
.min(1, 'State/Province/Region is required'),
postalCode: z
.string()
.min(1, 'Postal code is required'),
country: z
.string()
.min(1, 'Country is required')
.length(2, 'Please use 2-letter country code (e.g., US, GB, CA)')
});
export type IntlAddressFormData = z.infer<typeof intlAddressSchema>;
// =============================================================================
// PROFILE SCHEMAS
// =============================================================================
/**
* Basic profile schema
*/
export const basicProfileSchema = z.object({
firstName: nameFieldSchema,
lastName: nameFieldSchema,
email: z
.string()
.email('Please enter a valid email address'),
phone: phoneSchema
});
export type BasicProfileFormData = z.infer<typeof basicProfileSchema>;
/**
* Extended profile with bio and website
*/
export const extendedProfileSchema = basicProfileSchema.extend({
bio: z
.string()
.max(500, 'Bio must be 500 characters or less')
.optional(),
website: z
.string()
.url('Please enter a valid URL (e.g., https://example.com)')
.or(z.literal(''))
.optional(),
company: z
.string()
.max(100, 'Company name must be 100 characters or less')
.optional(),
jobTitle: z
.string()
.max(100, 'Job title must be 100 characters or less')
.optional()
});
export type ExtendedProfileFormData = z.infer<typeof extendedProfileSchema>;
/**
* Full profile with address
*/
export const fullProfileSchema = extendedProfileSchema.extend({
address: usAddressSchema.optional()
});
export type FullProfileFormData = z.infer<typeof fullProfileSchema>;
// =============================================================================
// CONTACT FORM
// =============================================================================
/**
* Contact form schema
*/
export const contactFormSchema = z.object({
name: z
.string()
.min(1, 'Please enter your name'),
email: z
.string()
.min(1, 'Please enter your email')
.email('Please enter a valid email address'),
phone: phoneSchema,
subject: z
.string()
.min(1, 'Please select a subject')
.optional(),
message: z
.string()
.min(10, 'Message must be at least 10 characters')
.max(2000, 'Message must be 2000 characters or less')
});
export type ContactFormData = z.infer<typeof contactFormSchema>;
// =============================================================================
// PREFERENCES
// =============================================================================
/**
* Notification preferences
*/
export const notificationPreferencesSchema = z.object({
emailNotifications: z.boolean().default(true),
smsNotifications: z.boolean().default(false),
pushNotifications: z.boolean().default(true),
marketingEmails: z.boolean().default(false),
weeklyDigest: z.boolean().default(true)
});
export type NotificationPreferencesFormData = z.infer<typeof notificationPreferencesSchema>;
/**
* Privacy settings
*/
export const privacySettingsSchema = z.object({
profileVisibility: z.enum(['public', 'private', 'contacts'], {
errorMap: () => ({ message: 'Please select a visibility option' })
}),
showEmail: z.boolean().default(false),
showPhone: z.boolean().default(false),
allowSearchEngines: z.boolean().default(false)
});
export type PrivacySettingsFormData = z.infer<typeof privacySettingsSchema>;
/**
* Validation Timing Utilities
*
* Implements the "Reward Early, Punish Late" pattern backed by UX research.
*
* Research basis:
* - Luke Wroblewski's inline validation study (2009)
* - Jessica Enders' "Designing UX: Forms" findings
* - Industry A/B testing results
*
* @module validation-timing
*/
// =============================================================================
// TYPES
// =============================================================================
/**
* When validation should trigger
*/
export type ValidationTrigger =
| 'onChange' // Every keystroke
| 'onBlur' // When field loses focus
| 'onSubmit' // Only on form submission
| 'all'; // All events
/**
* Validation timing configuration
*/
export interface ValidationTimingConfig {
/** When to first show errors (punish late = onBlur) */
showErrorsOn: ValidationTrigger;
/** When to re-validate after first error (real-time correction = onChange) */
revalidateOn: ValidationTrigger;
/** When to show valid state (reward early = onChange) */
showValidOn: ValidationTrigger;
/** Debounce delay for onChange validation (ms) */
debounceMs?: number;
/** Whether to validate on mount */
validateOnMount?: boolean;
}
/**
* Field validation state
*/
export interface FieldValidationState {
/** Field has been touched (focused and blurred) */
touched: boolean;
/** Field value has changed from initial */
dirty: boolean;
/** Field has shown an error at least once */
hasShownError: boolean;
/** Current error message (if any) */
error: string | undefined;
/** Field is currently valid */
isValid: boolean;
}
// =============================================================================
// TIMING PRESETS
// =============================================================================
/**
* Pre-configured timing presets for common use cases
*/
export const TIMING_PRESETS = {
/**
* Standard: Reward Early, Punish Late (RECOMMENDED)
*
* - Shows green checkmark immediately when valid
* - Delays red error until user leaves field
* - Real-time updates during error correction
*
* Best for: Most forms
*/
standard: {
showErrorsOn: 'onBlur',
revalidateOn: 'onChange',
showValidOn: 'onChange',
validateOnMount: false
} as ValidationTimingConfig,
/**
* Real-time: Immediate feedback on everything
*
* - Shows both valid and invalid immediately
* - Good for password strength, character counts
*
* Best for: Password fields, search, live counters
* Avoid for: Email, phone, complex fields
*/
realtime: {
showErrorsOn: 'onChange',
revalidateOn: 'onChange',
showValidOn: 'onChange',
validateOnMount: false
} as ValidationTimingConfig,
/**
* Submit Only: No inline validation
*
* - All validation happens on submit
* - Simplest user experience
*
* Best for: Very short forms (1-2 fields), login
*/
submitOnly: {
showErrorsOn: 'onSubmit',
revalidateOn: 'onSubmit',
showValidOn: 'onSubmit',
validateOnMount: false
} as ValidationTimingConfig,
/**
* Debounced: Delayed validation for expensive checks
*
* - Waits for user to stop typing
* - Good for async validation (username check)
*
* Best for: Server-side validation, search-as-you-type
*/
debounced: {
showErrorsOn: 'onBlur',
revalidateOn: 'onChange',
showValidOn: 'onChange',
debounceMs: 500,
validateOnMount: false
} as ValidationTimingConfig,
/**
* Aggressive: Validate early and often
*
* - Shows errors on change, but only after touched
* - Good for forms where mistakes are costly
*
* Best for: Financial forms, legal forms
*/
aggressive: {
showErrorsOn: 'onChange',
revalidateOn: 'onChange',
showValidOn: 'onChange',
validateOnMount: false
} as ValidationTimingConfig
} as const;
// =============================================================================
// REACT HOOK FORM INTEGRATION
// =============================================================================
/**
* Convert timing config to React Hook Form options
*
* @example
* ```tsx
* const { register } = useForm({
* ...toRHFOptions(TIMING_PRESETS.standard),
* resolver: zodResolver(schema)
* });
* ```
*/
export function toRHFOptions(timing: ValidationTimingConfig) {
return {
mode: timing.showErrorsOn === 'all' ? 'all' as const : timing.showErrorsOn as 'onChange' | 'onBlur' | 'onSubmit',
reValidateMode: timing.revalidateOn === 'all' ? 'onChange' as const : timing.revalidateOn as 'onChange' | 'onBlur' | 'onSubmit',
shouldFocusError: true,
criteriaMode: 'firstError' as const
};
}
// =============================================================================
// TANSTACK FORM INTEGRATION
// =============================================================================
/**
* Convert timing config to TanStack Form options
*
* @example
* ```tsx
* const form = useForm({
* ...toTanStackOptions(TIMING_PRESETS.standard),
* validatorAdapter: zodValidator()
* });
* ```
*/
export function toTanStackOptions(timing: ValidationTimingConfig) {
const options: Record<string, unknown> = {};
// Map triggers to TanStack validators
if (timing.showErrorsOn === 'onBlur' || timing.showErrorsOn === 'all') {
options.onBlur = true;
}
if (timing.revalidateOn === 'onChange' || timing.revalidateOn === 'all') {
options.onChange = true;
}
if (timing.debounceMs) {
options.onChangeAsyncDebounceMs = timing.debounceMs;
}
return options;
}
// =============================================================================
// VALIDATION STATE MACHINE
// =============================================================================
/**
* Determines whether to show error based on timing config and field state
*
* This implements the "Reward Early, Punish Late" logic:
* - Valid state shows immediately (reward early)
* - Error shows only after blur (punish late)
* - Once error shown, updates in real-time (correction mode)
*/
export function shouldShowError(
state: FieldValidationState,
timing: ValidationTimingConfig = TIMING_PRESETS.standard
): boolean {
// No error to show
if (!state.error) {
return false;
}
// Once an error has been shown, always show it (correction mode)
if (state.hasShownError) {
return true;
}
// Check timing config
switch (timing.showErrorsOn) {
case 'onChange':
return state.dirty;
case 'onBlur':
return state.touched;
case 'onSubmit':
return false; // Handled by form submit
case 'all':
return state.touched || state.dirty;
default:
return state.touched;
}
}
/**
* Determines whether to show valid state based on timing config
*/
export function shouldShowValid(
state: FieldValidationState,
timing: ValidationTimingConfig = TIMING_PRESETS.standard
): boolean {
// Not valid
if (!state.isValid) {
return false;
}
// Check timing config
switch (timing.showValidOn) {
case 'onChange':
return state.dirty;
case 'onBlur':
return state.touched;
case 'onSubmit':
return false;
case 'all':
return state.touched || state.dirty;
default:
return state.dirty;
}
}
/**
* Get visual state for field based on validation state and timing
*/
export function getFieldVisualState(
state: FieldValidationState,
timing: ValidationTimingConfig = TIMING_PRESETS.standard
): 'idle' | 'valid' | 'invalid' {
if (shouldShowError(state, timing)) {
return 'invalid';
}
if (shouldShowValid(state, timing)) {
return 'valid';
}
return 'idle';
}
// =============================================================================
// DEBOUNCE UTILITY
// =============================================================================
/**
* Creates a debounced validator function
*
* @example
* ```tsx
* const checkUsername = createDebouncedValidator(
* async (value) => {
* const response = await fetch(`/api/check-username?u=${value}`);
* const { available } = await response.json();
* return available ? undefined : 'Username is taken';
* },
* 500
* );
* ```
*/
export function createDebouncedValidator<T>(
validator: (value: T) => Promise<string | undefined>,
delay: number = 500
): (value: T) => Promise<string | undefined> {
let timeoutId: ReturnType<typeof setTimeout>;
let latestValue: T;
let latestResolve: ((error: string | undefined) => void) | null = null;
return (value: T): Promise<string | undefined> => {
latestValue = value;
// Clear any pending validation
clearTimeout(timeoutId);
return new Promise((resolve) => {
// Store the resolve function
latestResolve = resolve;
timeoutId = setTimeout(async () => {
// Only validate if this is still the latest value
if (value === latestValue && latestResolve === resolve) {
try {
const error = await validator(value);
resolve(error);
} catch (e) {
resolve('Validation failed');
}
} else {
// Value changed, resolve with no error (will be re-validated)
resolve(undefined);
}
}, delay);
});
};
}
// =============================================================================
// FIELD TIMING HOOK (React)
// =============================================================================
/**
* React hook for managing field validation timing
*
* @example
* ```tsx
* function EmailField() {
* const { fieldState, handlers } = useFieldTiming('email', {
* validate: (value) => validateEmail(value),
* timing: TIMING_PRESETS.standard
* });
*
* return (
* <input
* {...handlers}
* className={fieldState.visualState}
* />
* );
* }
* ```
*/
export interface UseFieldTimingOptions<T> {
/** Validation function */
validate: (value: T) => string | undefined | Promise<string | undefined>;
/** Timing preset or custom config */
timing?: ValidationTimingConfig;
/** Initial value */
initialValue?: T;
}
export interface UseFieldTimingResult<T> {
/** Current field value */
value: T;
/** Field validation state */
fieldState: FieldValidationState & { visualState: 'idle' | 'valid' | 'invalid' };
/** Event handlers to spread on input */
handlers: {
onChange: (e: { target: { value: T } }) => void;
onBlur: () => void;
onFocus: () => void;
};
/** Manually trigger validation */
validate: () => Promise<void>;
/** Reset field state */
reset: () => void;
}
// Note: Actual React implementation would go here
// This is the interface/types for documentation
Related skills
FAQ
What validation timing does it recommend?
Reward early and punish late: show valid immediately, show invalid only on blur, then re-validate in real time during correction.
Why Zod?
To use one schema as the single source of truth for both runtime validation and inferred TypeScript types.