form-validation
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseForm Validation
表单验证
Schema-first validation using Zod as the single source of truth for both runtime validation and TypeScript types.
使用Zod作为运行时验证和TypeScript类型的唯一可信来源的Schema优先验证方案。
Quick Start
快速开始
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)
});typescript
import { z } from 'zod';
// 1. 定义Schema(验证规则与类型二合一)
const schema = z.object({
email: z.string().min(1, '必填').email('无效邮箱'),
age: z.number().positive().optional()
});
// 2. 推导TypeScript类型(无需手动定义)
type FormData = z.infer<typeof schema>;
// 3. 结合表单库使用
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 |
这是经UX研究验证的最优验证时机模式:
| 事件 | 显示正确状态(✓) | 显示错误状态(✗) | 原因 |
|---|---|---|---|
| 输入时 | ✅ 立即显示 | ❌ 绝不显示 | 不要在用户输入时打断 |
| 失去焦点时 | ✅ 立即显示 | ✅ 显示错误 | 用户已完成输入,展示错误 |
| 修正过程中 | ✅ 立即显示 | ✅ 实时更新 | 让用户快速修正 |
Implementation
实现示例
typescript
// 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)
}
});typescript
// React Hook Form
useForm({
mode: 'onBlur', // 首次验证在失去焦点时(晚验证提示错误)
reValidateMode: 'onChange' // 变更时重新验证(实时修正反馈)
});
// TanStack Form
useForm({
validators: {
onBlur: schema, // 失去焦点时验证
onChange: schema // 变更时重新验证(标记为已触碰后)
}
});Zod Schema Patterns
Zod Schema 常用模式
Basic Types
基础类型
typescript
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()
})typescript
import { z } from 'zod';
// 字符串类型
z.string() // 任意字符串
z.string().min(1, '必填') // 非空(比.nonempty()更友好)
z.string().email('无效邮箱')
z.string().url('无效URL')
z.string().uuid('无效ID')
z.string().regex(/^\d{5}$/, '无效邮政编码')
// 数字类型
z.number() // 任意数字
z.number().positive('必须为正数')
z.number().int('必须为整数')
z.number().min(0).max(100)
// 布尔类型
z.boolean()
z.literal(true) // 必须严格为true
// 枚举类型
z.enum(['admin', 'user', 'guest'])
// 数组类型
z.array(z.string())
z.array(z.string()).min(1, '至少选择一项')
// 对象类型
z.object({
name: z.string(),
email: z.string().email()
})Common Form Schemas
常见表单Schema
typescript
// 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']
});typescript
// 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')
});typescript
// 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;
}typescript
// schemas/auth.ts
export const loginSchema = z.object({
email: z
.string()
.min(1, '请输入邮箱')
.email('请输入有效的邮箱'),
password: z
.string()
.min(1, '请输入密码'),
rememberMe: z.boolean().optional().default(false)
});
export const registrationSchema = z.object({
email: z
.string()
.min(1, '邮箱为必填项')
.email('请输入有效的邮箱'),
password: z
.string()
.min(1, '密码为必填项')
.min(8, '密码长度至少为8位')
.regex(/[A-Z]/, '至少包含一个大写字母')
.regex(/[a-z]/, '至少包含一个小写字母')
.regex(/[0-9]/, '至少包含一个数字'),
confirmPassword: z
.string()
.min(1, '请确认密码')
}).refine(data => data.password === data.confirmPassword, {
message: '两次输入的密码不一致',
path: ['confirmPassword']
});
export const forgotPasswordSchema = z.object({
email: z
.string()
.min(1, '邮箱为必填项')
.email('请输入有效的邮箱')
});
export const resetPasswordSchema = z.object({
password: z
.string()
.min(8, '密码长度至少为8位'),
confirmPassword: z.string()
}).refine(data => data.password === data.confirmPassword, {
message: '两次输入的密码不一致',
path: ['confirmPassword']
});typescript
// schemas/profile.ts
export const profileSchema = z.object({
firstName: z.string().min(1, '名字为必填项'),
lastName: z.string().min(1, '姓氏为必填项'),
email: z.string().email('无效邮箱'),
phone: z
.string()
.regex(/^\+?[\d\s-()]+$/, '无效电话号码')
.optional()
.or(z.literal('')),
bio: z
.string()
.max(500, '个人简介不能超过500字符')
.optional()
});
export const addressSchema = z.object({
street: z.string().min(1, '街道地址为必填项'),
city: z.string().min(1, '城市为必填项'),
state: z.string().min(1, '州/省份为必填项'),
zip: z.string().regex(/^\d{5}(-\d{4})?$/, '无效邮政编码'),
country: z.string().min(1, '国家为必填项').default('US')
});typescript
// schemas/payment.ts
export const paymentSchema = z.object({
cardName: z.string().min(1, '持卡人姓名为必填项'),
cardNumber: z
.string()
.regex(/^\d{13,19}$/, '无效卡号')
.refine(val => luhnCheck(val), '无效卡号'),
expMonth: z
.string()
.regex(/^(0[1-9]|1[0-2])$/, '无效月份'),
expYear: z
.string()
.regex(/^\d{2}$/, '无效年份')
.refine(val => {
const year = parseInt(val, 10) + 2000;
return year >= new Date().getFullYear();
}, '卡片已过期'),
cvc: z.string().regex(/^\d{3,4}$/, '无效CVC码')
});
// 用于卡号验证的Luhn算法
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
条件验证
typescript
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']
}
);typescript
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: '选择配送方式时必须填写地址',
path: ['address']
}
);Cross-Field Validation
跨字段验证
typescript
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']
}
);typescript
const dateRangeSchema = z.object({
startDate: z.date(),
endDate: z.date()
}).refine(
data => data.endDate >= data.startDate,
{
message: '结束日期不能早于开始日期',
path: ['endDate']
}
);Schema Composition
Schema 组合
typescript
// 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'])
});typescript
// 基础Schema
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()
});
// 组合后的Schema
const userSchema = nameSchema.merge(contactSchema).extend({
role: z.enum(['admin', 'user'])
});Async Validation
异步验证
For server-side checks (username availability, email uniqueness):
typescript
// 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;
}
}
});用于服务端校验(用户名可用性、邮箱唯一性等):
typescript
// 使用Zod refine
const usernameSchema = z
.string()
.min(3, '用户名长度至少为3位')
.refine(
async (username) => {
const response = await fetch(`/api/check-username?u=${encodeURIComponent(username)}`);
const { available } = await response.json();
return available;
},
{ message: '该用户名已被占用' }
);
// 使用TanStack Form(内置防抖)
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: '用户名已被占用' } };
}
return undefined;
}
}
});Debounced Validation Helper
防抖验证工具
typescript
// 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);typescript
// 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 () => {
// 仅当当前值为最新值时才验证
if (value === latestValue) {
const error = await validator(value);
resolve(error);
} else {
resolve(undefined);
}
}, delay);
});
};
}
// 使用示例
const checkUsername = createDebouncedValidator(async (username: string) => {
const response = await fetch(`/api/check-username?u=${username}`);
const { available } = await response.json();
return available ? undefined : '用户名已被占用';
}, 500);Error Messages
错误消息
Principles
设计原则
- Specific: Tell users exactly what's wrong
- Actionable: Tell users how to fix it
- Contextual: Reference the field name
- Friendly: Don't blame the user
- 具体明确: 准确告知用户问题所在
- 可操作: 告诉用户如何修正
- 上下文关联: 关联对应的字段名称
- 友好: 不要指责用户
Examples
示例对比
typescript
// ❌ 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')
});typescript
// ❌ 不好:通用、无帮助
const badSchema = z.object({
email: z.string().email(), // "无效"
password: z.string().min(8), // "太短"
phone: z.string().regex(/^\d+$/) // "无效"
});
// ✅ 良好:具体、可操作
const goodSchema = z.object({
email: z
.string()
.min(1, '请输入您的邮箱地址')
.email('请输入有效的邮箱(例如:name@example.com)'),
password: z
.string()
.min(1, '请设置密码')
.min(8, '密码长度至少为8位'),
phone: z
.string()
.regex(/^\d{10}$/, '请输入10位电话号码')
});Message Templates
消息模板
typescript
// 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))
});typescript
// utils/validation-messages.ts
export const messages = {
required: (field: string) => `请输入您的${field}`,
email: '请输入有效的邮箱地址',
minLength: (field: string, min: number) =>
`${field}长度至少为${min}位`,
maxLength: (field: string, max: number) =>
`${field}长度不能超过${max}位`,
pattern: (field: string, example: string) =>
`请输入有效的${field}(例如:${example})`,
match: (field: string) => `两个${field}必须一致`,
unique: (field: string) => `该${field}已被使用`,
future: (field: string) => `${field}必须是未来日期`,
past: (field: string) => `${field}必须是过去日期`
};
// 使用示例
const schema = z.object({
email: z
.string()
.min(1, messages.required('邮箱'))
.email(messages.email),
password: z
.string()
.min(1, messages.required('密码'))
.min(8, messages.minLength('密码', 8))
});Validation Timing Utility
验证时机工具
typescript
// 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
};
}typescript
// utils/validation-timing.ts
export type ValidationMode = 'onBlur' | 'onChange' | 'onSubmit' | 'all';
export interface ValidationTimingConfig {
/** 首次显示错误的时机 */
showErrorsOn: ValidationMode;
/** 首次错误后重新验证的时机 */
revalidateOn: ValidationMode;
/** onChange的防抖延迟(毫秒) */
debounceMs?: number;
}
export const TIMING_PRESETS = {
/** 默认:早验证反馈正确、晚验证提示错误 */
standard: {
showErrorsOn: 'onBlur',
revalidateOn: 'onChange'
} as ValidationTimingConfig,
/** 适用于密码强度、字符计数场景 */
realtime: {
showErrorsOn: 'onChange',
revalidateOn: 'onChange'
} as ValidationTimingConfig,
/** 适用于简单短表单 */
submitOnly: {
showErrorsOn: 'onSubmit',
revalidateOn: 'onSubmit'
} as ValidationTimingConfig,
/** 适用于开销较大的异步验证 */
debounced: {
showErrorsOn: 'onBlur',
revalidateOn: 'onChange',
debounceMs: 500
} as ValidationTimingConfig
} as const;
// React Hook Form 配置映射
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 templatesform-validation/
├── SKILL.md
├── references/
│ ├── zod-patterns.md # Zod模式深度解析
│ ├── timing-research.md # 验证时机的UX研究
│ └── error-message-guide.md # 错误消息编写指南
└── scripts/
├── schemas/
│ ├── auth.ts # 登录、注册、密码重置
│ ├── profile.ts # 用户资料、地址
│ ├── payment.ts # 信用卡、账单
│ └── common.ts # 可复用字段Schema
├── validation-timing.ts # 验证时机工具
├── async-validator.ts # 防抖异步验证工具
└── messages.ts # 错误消息模板Framework Integration
框架集成
| Framework | Adapter | Import |
|---|---|---|
| React Hook Form | @hookform/resolvers/zod | |
| TanStack Form | @tanstack/zod-form-adapter | |
| VeeValidate | @vee-validate/zod | |
| Vanilla | Direct | |
| 框架 | 适配器 | 导入方式 |
|---|---|---|
| React Hook Form | @hookform/resolvers/zod | |
| TanStack Form | @tanstack/zod-form-adapter | |
| VeeValidate | @vee-validate/zod | |
| Vanilla | 直接使用 | |
Reference
参考资料
- — Complete Zod API patterns
references/zod-patterns.md - — UX research backing timing decisions
references/timing-research.md - — Writing effective error messages
references/error-message-guide.md
- — 完整Zod API模式
references/zod-patterns.md - — 验证时机决策的UX研究依据
references/timing-research.md - — 编写有效错误消息的指南
references/error-message-guide.md