mcpField
F
form-validation

form-validation

github.com/bbeierle12Development964 installsUpdated 1d ago
Repository

About form-validation

form-validation is a skill for building schema-first form validation with Zod, using schemas for both runtime checks and TypeScript types. It covers validation timing, async checks, and framework adapters for developers implementing forms.

Key features

  1. 01Zod schemas for validation and types
  2. 02Validation timing guidance
  3. 03Conditional and cross-field rules
  4. 04Async validation patterns
  5. 05Framework adapter examples
  6. 06Actionable error-message guidance

Use cases

  1. 01Define reusable login and profile schemas
  2. 02Validate fields after blur and during correction
  3. 03Check username availability asynchronously
  4. 04Connect schemas to form libraries

Install

Copy the command and run it in a terminal.

$npx skills add https://github.com/bbeierle12/skill-mcp-claude --skill form-validation

After installing, type/form-validationin Claude Code to confirm it loaded.

Bundled files

References and scripts installed along with SKILL.md.

_meta.json
scripts/
validation-timing.ts
scripts/schemas/
auth.ts
payment.ts
profile.ts

Details

From the project README

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:

EventShow Valid (✓)Show Invalid (✗)Why
On input✅ Immediately❌ NeverDon't yell while typing
On blur✅ Immediately✅ YesUser finished, show errors
During correction✅ Immediately✅ Real-timeLet 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., [email protected])'),
  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 templates

Framework Integration

FrameworkAdapterImport
React Hook Form@hookform/resolvers/zodzodResolver(schema)
TanStack Form@tanstack/zod-form-adapterzodValidator()
VeeValidate@vee-validate/zodtoTypedSchema(schema)
VanillaDirectschema.safeParse(data)

Reference

  • references/zod-patterns.md — Complete Zod API patterns
  • references/timing-research.md — UX research backing timing decisions
  • references/error-message-guide.md — Writing effective error messages

Comments

Markdown supported