mcpField
F
form-validation

form-validation

github.com/bbeierle12开发964 次安装1 天前更新
代码仓库

关于 form-validation

form-validation 是一项使用 Zod 实现模式优先表单校验的技能,让运行时校验与 TypeScript 类型共用模式。内容涵盖校验时机、异步检查及表单框架适配,适合实现表单的开发者。

主要功能

  1. 01使用 Zod 模式校验并生成类型
  2. 02校验时机指导
  3. 03条件与跨字段规则
  4. 04异步校验模式
  5. 05表单框架适配示例
  6. 06可执行的错误消息指导

适用场景

  1. 01定义可复用的登录与个人资料模式
  2. 02设置失焦后及修正过程中的校验
  3. 03异步检查用户名是否可用
  4. 04将模式接入表单库

安装

复制下面的命令,在终端中运行。

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

安装后在 Claude Code 中输入/form-validation确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

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

详细介绍

表单验证

以 Schema 优先的验证方式,使用 Zod 作为运行时验证和 TypeScript 类型的唯一事实来源。

快速开始

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)
});

核心原则:先鼓励,后纠错

这是经过 UX 研究支持的最佳验证时机模式:

事件显示有效(✓)显示无效(✗)原因
输入时✅ 立即❌ 从不不要在用户输入时大声指责
失焦时✅ 立即✅ 是用户已完成,显示错误
更正时✅ 立即✅ 实时让用户快速修正
实现
// 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 模式

基本类型
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()
})
常见表单 Schema
// 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;
}
高级模式
条件验证
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']
  }
);
跨字段验证
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 组合
// 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'])
});

异步验证

用于服务器端检查(用户名是否可用、电子邮件是否唯一):

// 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;
    }
  }
});
防抖验证辅助工具
// 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);

错误消息

原则
  1. 具体:准确告诉用户哪里出了问题
  2. 可操作:告诉用户如何修正
  3. 结合上下文:引用字段名称
  4. 友好:不要责怪用户
示例
// ❌ 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')
});
消息模板
// 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))
});

验证时机工具

// 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
  };
}

文件结构

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

框架集成

框架适配器导入
React Hook Form@hookform/resolvers/zodzodResolver(schema)
TanStack Form@tanstack/zod-form-adapterzodValidator()
VeeValidate@vee-validate/zodtoTypedSchema(schema)
Vanilla直接使用schema.safeParse(data)

参考

  • references/zod-patterns.md — 完整的 Zod API 模式
  • references/timing-research.md — 支持验证时机决策的 UX 研究
  • references/error-message-guide.md — 撰写有效错误消息

评论

支持 Markdown