前置知识: TypeScript

类型安全的表单验证

00:00
3 min Advanced 2026/6/14

构建类型安全的表单验证系统

概述

表单验证是前端应用中最常见的交互逻辑之一。传统的表单验证方案中,字段名、验证规则和错误信息之间缺乏类型关联,容易出现字段名拼写错误、验证规则与字段类型不匹配等问题。通过 TypeScript 的泛型、条件类型和映射类型,可以构建类型安全的表单验证系统,确保字段名、值类型、验证规则和错误信息都有精确的类型约束。

基础概念

表单 Schema:定义表单字段及其验证规则的数据结构。每个字段包含类型信息、验证规则和错误消息模板。

字段类型映射:将表单 Schema 转换为字段值类型,确保表单数据的类型安全。

验证规则:每个字段验证函数列表接收值并返回验证结果验证规则的参数类型与字段值类型关联。

错误类型验证失败时返回错误信息,键名与字段对应,错误信息类型字符串字符串数组。

快速上手

基础表单类型

// 定义表单字段类型
interface FormSchema {
  username: string;
  email: string;
  age: number;
  agree: boolean;
}

// 从 Schema 生成表单数据类型
type FormData<T> = { [K in keyof T]: T[K] };

// 从 Schema 生成错误类型
type FormErrors<T> = {
  [K in keyof T]?: string[];
};

// 从 Schema 生成 touched 类型
type FormTouched<T> = {
  [K in keyof T]?: boolean;
};

验证函数

// 验证规则类型
type ValidationRule<T> = (value: T) => string | null;

// 通用验证规则
const required =
  (message = '此字段为必填项'): ValidationRule<any> =>
  (value) =>
    value == null || value === '' ? message : null;

const minLength =
  (min: number, message?: string): ValidationRule<string> =>
  (value) =>
    value.length < min ? (message ?? `最少需要 ${min} 个字符`) : null;

const maxLength =
  (max: number, message?: string): ValidationRule<string> =>
  (value) =>
    value.length > max ? (message ?? `最多允许 ${max} 个字符`) : null;

const min =
  (minimum: number, message?: string): ValidationRule<number> =>
  (value) =>
    value < minimum ? (message ?? `最小值为 ${minimum}`) : null;

const max =
  (maximum: number, message?: string): ValidationRule<number> =>
  (value) =>
    value > maximum ? (message ?? `最大值为 ${maximum}`) : null;

const pattern =
  (regex: RegExp, message: string): ValidationRule<string> =>
  (value) =>
    !regex.test(value) ? message : null;

const email =
  (message = '请输入有效的邮箱地址'): ValidationRule<string> =>
  (value) =>
    !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? message : null;

详细用法

类型安全的表单验证器

// 表单验证器
class FormValidator<T extends Record<string, any>> {
  private rules: { [K in keyof T]?: ValidationRule<T[K]>[] } = {};

  // 添加字段的验证规则
  field<K extends keyof T>(key: K, ...validators: ValidationRule<T[K]>[]): this {
    this.rules[key] = validators;
    return this;
  }

  // 验证单个字段
  validateField<K extends keyof T>(key: K, value: T[K]): string[] {
    const fieldRules = this.rules[key] ?? [];
    return fieldRules.map((rule) => rule(value)).filter((error): error is string => error !== null);
  }

  // 验证整个表单
  validate(data: FormData<T>): FormErrors<T> {
    const errors: FormErrors<T> = {};
    for (const key in this.rules) {
      const fieldErrors = this.validateField(key, data[key]);
      if (fieldErrors.length > 0) {
        errors[key] = fieldErrors;
      }
    }
    return errors;
  }

  // 检查表单是否有效
  isValid(data: FormData<T>): boolean {
    const errors = this.validate(data);
    return Object.keys(errors).length === 0;
  }
}

// 使用
interface RegisterForm {
  username: string;
  email: string;
  age: number;
  agree: boolean;
}

const registerValidator = new FormValidator<RegisterForm>()
  .field('username', required(), minLength(3), maxLength(20))
  .field('email', required(), email())
  .field('age', required(), min(1), max(150))
  .field('agree', required('请同意服务条款'));

// 验证
const errors = registerValidator.validate({
  username: 'ab',
  email: 'invalid',
  age: -1,
  agree: false,
});
// { username: ['最少需要 3 个字符'], email: ['请输入有效的邮箱地址'], age: ['最小值为 1'], agree: ['请同意服务条款'] }

异步验证

// 异步验证规则
type AsyncValidationRule<T> = (value: T) => Promise<string | null>;

class AsyncFormValidator<T extends Record<string, any>> {
  private syncRules: { [K in keyof T]?: ValidationRule<T[K]>[] } = {};
  private asyncRules: { [K in keyof T]?: AsyncValidationRule<T[K]>[] } = {};

  field<K extends keyof T>(
    key: K,
    syncValidators: ValidationRule<T[K]>[],
    asyncValidators?: AsyncValidationRule<T[K]>[]
  ): this {
    this.syncRules[key] = syncValidators;
    if (asyncValidators) this.asyncRules[key] = asyncValidators;
    return this;
  }

  async validateField<K extends keyof T>(key: K, value: T[K]): Promise<string[]> {
    // 先执行同步验证
    const syncErrors = (this.syncRules[key] ?? [])
      .map((rule) => rule(value))
      .filter((error): error is string => error !== null);

    if (syncErrors.length > 0) return syncErrors;

    // 同步验证通过后执行异步验证
    const asyncErrors = await Promise.all((this.asyncRules[key] ?? []).map((rule) => rule(value)));
    return asyncErrors.filter((error): error is string => error !== null);
  }

  async validate(data: FormData<T>): Promise<FormErrors<T>> {
    const errors: FormErrors<T> = {};
    for (const key in { ...this.syncRules, ...this.asyncRules }) {
      const fieldErrors = await this.validateField(key, data[key]);
      if (fieldErrors.length > 0) {
        errors[key] = fieldErrors;
      }
    }
    return errors;
  }
}

// 使用:用户名唯一性检查
const uniqueUsername: AsyncValidationRule<string> = async (value) => {
  const response = await fetch(`/api/users/check?username=${value}`);
  const { available } = await response.json();
  return available ? null : '用户名已被占用';
};

const validator = new AsyncFormValidator<RegisterForm>()
  .field('username', [required(), minLength(3)], [uniqueUsername])
  .field('email', [required(), email()]);

条件验证

// 条件验证:根据其他字段的值决定是否验证
type ConditionalRule<T> = {
  dependsOn: keyof T;
  condition: (value: T[keyof T]) => boolean;
  rules: ValidationRule<any>[];
};

class ConditionalFormValidator<T extends Record<string, any>> {
  private rules: { [K in keyof T]?: ValidationRule<T[K]>[] } = {};
  private conditionalRules: ConditionalRule<T>[] = [];

  field<K extends keyof T>(key: K, ...validators: ValidationRule<T[K]>[]): this {
    this.rules[key] = validators;
    return this;
  }

  when<K extends keyof T>(
    dependsOn: K,
    condition: (value: T[K]) => boolean,
    fieldRules: { [P in keyof T]?: ValidationRule<T[P]>[] }
  ): this {
    for (const [field, validators] of Object.entries(fieldRules)) {
      if (validators) {
        this.conditionalRules.push({
          dependsOn,
          condition: condition as any,
          rules: validators,
        });
      }
    }
    return this;
  }

  validate(data: FormData<T>): FormErrors<T> {
    const errors: FormErrors<T> = {};

    // 基础验证
    for (const key in this.rules) {
      const fieldErrors = (this.rules[key] ?? [])
        .map((rule) => rule(data[key]))
        .filter((error): error is string => error !== null);
      if (fieldErrors.length > 0) errors[key] = fieldErrors;
    }

    // 条件验证
    for (const rule of this.conditionalRules) {
      if (rule.condition(data[rule.dependsOn])) {
        const fieldErrors = rule.rules
          .map((r) => r(data[Object.keys(this.rules).find(() => true) as keyof T]))
          .filter((error): error is string => error !== null);
        if (fieldErrors.length > 0) {
          // 合并条件验证错误
        }
      }
    }

    return errors;
  }
}

常见场景

动态表单

// 动态表单:字段在运行时确定
interface DynamicField {
  name: string;
  type: 'text' | 'number' | 'email' | 'select';
  label: string;
  required: boolean;
  rules: ValidationRule<any>[];
  options?: { label: string; value: string }[];
}

class DynamicFormValidator {
  private fields: DynamicField[] = [];

  addField(field: DynamicField): this {
    this.fields.push(field);
    return this;
  }

  validate(data: Record<string, any>): Record<string, string[]> {
    const errors: Record<string, string[]> = {};
    for (const field of this.fields) {
      const value = data[field.name];
      const fieldErrors = field.rules
        .map((rule) => rule(value))
        .filter((error): error is string => error !== null);
      if (fieldErrors.length > 0) {
        errors[field.name] = fieldErrors;
      }
    }
    return errors;
  }

  getDefaults(): Record<string, any> {
    return Object.fromEntries(
      this.fields.map((field) => [field.name, field.type === 'number' ? 0 : ''])
    );
  }
}

注意事项

  • 验证时机表单验证应在适当的时机执行提交时、字段失焦时、实时验证等)。过于频繁的验证影响用户体验和性能
  • 错误信息国际化验证错误信息支持国际化。将错误消息模板与验证逻辑分离,方便翻译
  • 性能优化:异步验证应添加防抖(debounce),避免频繁请求服务器。可以使用 AbortController 取消过期的验证请求。
  • 类型安全与运行时TypeScript类型安全仅在编译时有效。运行时仍需验证数据式,特别是来自 API 的数据。

进阶用法

Zod Schema 集成

import { z } from 'zod';

// 使用 Zod 定义表单 Schema
const registerSchema = z
  .object({
    username: z.string().min(3, '最少3个字符').max(20, '最多20个字符'),
    email: z.string().email('请输入有效的邮箱地址'),
    age: z.number().min(1, '年龄必须大于0').max(150, '年龄不能超过150'),
    password: z.string().min(8, '密码至少8位'),
    confirmPassword: z.string(),
  })
  .refine((data) => data.password === data.confirmPassword, {
    message: '两次密码不一致',
    path: ['confirmPassword'],
  });

type RegisterFormData = z.infer<typeof registerSchema>;

// 验证
function validateWithZod(data: unknown) {
  const result = registerSchema.safeParse(data);
  if (!result.success) {
    // 将 Zod 错误转换为表单错误格式
    const errors: Record<string, string[]> = {};
    for (const issue of result.error.issues) {
      const path = issue.path.join('.');
      if (!errors[path]) errors[path] = [];
      errors[path].push(issue.message);
    }
    return { success: false, errors };
  }
  return { success: true, data: result.data };
}

知识检测

学习进度

-- 已学文档
--% 知识覆盖率

学习推荐

专注模式