【高级】AccessGuard v1.5:泛型表单系统 — TypeScript 类型安全的 Form Builder 深度实战

【高级】AccessGuard v1.5:泛型表单系统 --- TypeScript 类型安全的 Form Builder 深度实战

前言

  • 核心痛点:在企业级 IAM 系统中,权限策略表单字段众多、嵌套层级深、字段间存在复杂的联动校验关系。传统表单实现中,类型定义与校验逻辑分离,字段路径缺乏类型保护,提交值类型与初始值类型不一致导致运行时错误频发。本文用 TypeScript 泛型系统构建一套从 Schema 到初始值到提交值的全链路类型安全表单引擎
  • 前置知识:TypeScript 泛型基础、条件类型、映射类型、React 组件与 Hook 基础(建议先阅读本系列第一至十四篇)
  • 系列阶段:高级篇 第 5/6 篇(第十五篇)
  • 收获能力:掌握泛型表单字段建模、字段路径类型推断、Schema-初始值-提交值三态类型链、递归嵌套表单的类型设计,以及 React Hook Form 深度类型集成的原理与实战能力

目录

  • 技术背景与演进逻辑
  • 核心原理深度解析
    • 表单类型系统的三大支柱
    • React Hook Form 的类型推断架构
    • Schema 驱动的类型推导链
  • 核心模块详解
    • Field 泛型字段建模
    • 字段路径的递归类型推断
    • 字段间联动校验的类型约束
    • Schema → 初始值 → 提交值 三态类型链
  • 实战落地
    • 权限策略表单完整实现
    • 递归嵌套表单(条件表达式树)
    • 生产避坑经验
  • 技术优缺点与适用场景
  • 全文总结
  • 本期专栏更新说明
  • 专栏推荐
  • 参考资料

技术背景与演进逻辑

表单开发的类型困境

在 AccessGuard 的权限策略编辑器中,一个典型的 ABAC 策略表单包含以下字段:

text 复制代码
策略名称(string)
├── 策略描述(string | undefined)
├── 效力("allow" | "deny")
├── 主体条件(嵌套对象)
│   ├── 属性类型("department" | "role" | "clearance")
│   ├── 操作符("eq" | "neq" | "in" | "contains")
│   └── 属性值(string | number | string[],取决于属性类型)
├── 资源条件(嵌套对象)
│   ├── 资源类型("document" | "api" | "dashboard")
│   └── 属性条件(数组,可动态增删)
└── 环境条件(可选嵌套对象)
    ├── 时间范围({ from: string, to: string } | undefined)
    └── IP 白名单(string[] | undefined)

这个表单有三个核心挑战:

  1. 字段路径类型安全register("subject.attributeType") 中的路径字符串必须与表单结构匹配,拼写错误应在编译期暴露
  2. 值类型依赖路径 :不同路径对应不同值类型------subject.attributeType 是字符串联合类型,resource.conditions 是对象数组,environment.ipWhiteliststring[] | undefined
  3. 联动校验的类型约束 :当 subject.attributeType"clearance" 时,subject.value 应约束为 number 类型;为 "department" 时约束为 string

传统做法是手写 FormValues 类型 + 独立的 Zod schema,两者各自维护,容易漂移。本文展示如何用 TypeScript 泛型将三者统一为一条类型推导链。

表单库的类型能力演进

时期 代表方案 类型能力 核心限制
2018 Formik + 手写类型 手动声明 FormValues 字段路径无类型检查
2020 React Hook Form v6 泛型 useForm<T>() 深层嵌套路径推断为 any
2022 React Hook Form v7 FieldPath<T> 递归路径 联合类型路径仍有限制
2024 Zod + zodResolver Schema 推导类型 类型与 schema 仍为两个独立事物
2025 TanStack Form defaultValues 推断 默认值必须表达完整类型范围
2026 Formisch + Valibot Schema 即类型(单一真相源) 仅支持 Valibot

React Hook Form 7.x 的类型系统已经相当成熟,配合 Zod 可以实现从 Schema 到表单类型的完整推导。本文在 RHF 7.x 基础上构建 AccessGuard 的泛型表单层。

核心原理深度解析

表单类型系统的三大支柱

一套完整的类型安全表单系统需要覆盖三个层面:

text 复制代码
[类型系统三大支柱]
    │
    ├── ① 路径类型安全(Path Safety)
    │       │
    │       └── register("subject.attributeType")
    │               → 编译期检查路径是否存在
    │               → 路径拼写错误 = 编译错误
    │
    ├── ② 值类型推断(Value Inference)
    │       │
    │       └── getValue("subject.attributeType")
    │               → 返回类型自动推断为联合类型
    │               → 无需手动 as 断言
    │
    └── ③ 校验类型约束(Validation Type Constraint)
            │
            └── 联动校验中,字段值类型
                    → 根据其他字段值动态收窄
                    → 编译期保证校验逻辑完备

React Hook Form 通过泛型参数 TFieldValues extends FieldValues 贯穿所有 API,实现第一和第二个支柱。第三个支柱需要我们用条件类型和映射类型在应用层实现。

React Hook Form 的类型推断架构

React Hook Form 的核心类型设计围绕一个泛型参数展开:

typescript 复制代码
// React Hook Form 的核心泛型签名
export type UseFormReturn<
  TFieldValues extends FieldValues = FieldValues,
  TContext = any,
  TTransformedValues extends FieldValues | undefined = undefined,
> = {
  register: UseFormRegister<TFieldValues>
  handleSubmit: UseFormHandleSubmit<TFieldValues>
  control: Control<TFieldValues, TContext>
  watch: UseFormWatch<TFieldValues>
  setValue: UseFormSetValue<TFieldValues>
  getValues: UseFormGetValues<TFieldValues>
  formState: FormState<TFieldValues>
  // ...
}

TFieldValues 是整个类型链的根。它决定了:

  • register(name)name 的合法值(FieldPath<TFieldValues>
  • watch(name) 的返回类型(FieldPathValue<TFieldValues, name>
  • formState.errors 的结构(FieldErrors<TFieldValues>
  • handleSubmit(onSubmit)onSubmit 参数的类型

这条类型链的关键在于 FieldPath<T>FieldPathValue<T, P> 两个递归类型:

typescript 复制代码
// FieldPath 将嵌套对象展开为所有合法路径的联合类型
type FieldPath<T> = T extends object
  ? { [K in keyof T & string]:
      K | (T[K] extends object ? `${K}.${FieldPath<T[K]>}` : never)
    }[keyof T & string]
  : never

// FieldPathValue 根据路径提取对应的值类型
type FieldPathValue<T, P extends string> =
  P extends `${infer K}.${infer Rest}`
    ? K extends keyof T
      ? FieldPathValue<T[K], Rest>
      : never
    : P extends keyof T
      ? T[P]
      : never

举个例子,给定表单类型:

typescript 复制代码
type PolicyForm = {
  name: string
  subject: {
    attributeType: "department" | "role" | "clearance"
    operator: "eq" | "neq" | "in"
    value: string | number
  }
  resources: Array<{
    resourceType: string
    conditions: Array<{ field: string; op: string; value: string }>
  }>
}

FieldPath<PolicyForm> 推导结果:

text 复制代码
"name"
| "subject"
| "subject.attributeType"
| "subject.operator"
| "subject.value"
| "resources"
| `resources.${number}`
| `resources.${number}.resourceType`
| `resources.${number}.conditions`
| `resources.${number}.conditions.${number}`
| `resources.${number}.conditions.${number}.field`
| `resources.${number}.conditions.${number}.op`
| `resources.${number}.conditions.${number}.value`

FieldPathValue<PolicyForm, "subject.attributeType"> 推导结果:"department" | "role" | "clearance"

Schema 驱动的类型推导链

在 AccessGuard 中,我们选择 Zod 作为 Schema 定义层,通过 z.infer 将 Schema 转换为 TypeScript 类型,再传入 useForm<T>()。这保证了 Schema 和类型的一致性:

text 复制代码
[Schema 驱动的类型推导链]
    │
    ├── Zod Schema(运行时校验)
    │       │
    │       └── z.object({ name: z.string(), ... })
    │
    ├── z.infer<typeof schema>(编译时类型)
    │       │
    │       └── { name: string, ... }
    │
    ├── useForm<FormValues>()(表单实例)
    │       │
    │       ├── register("name") → 类型安全的字段注册
    │       ├── watch("name") → 类型安全的值订阅
    │       └── handleSubmit(data => ...) → 类型安全的提交
    │
    └── zodResolver(schema)(运行时桥接)
            │
            └── 将 Zod 校验结果转换为 RHF 错误格式

这条链的核心优势:类型从 Schema 单向推导,不存在手动维护的独立类型。修改 Schema 后,所有下游类型自动更新。

核心模块详解

模块一:Field 泛型字段建模

在 AccessGuard 的表单系统中,每个表单字段需要承载比原始 HTML input 更多的信息:字段类型、校验规则、UI 组件类型、联动依赖。我们设计一个泛型 FormField<T> 来统一建模:

typescript 复制代码
import type { FieldValues, Path, UseFormRegister } from "react-hook-form"

// 字段类型枚举
type FieldType =
  | "text"
  | "number"
  | "select"
  | "multi-select"
  | "date-range"
  | "expression-tree"

// 字段配置的泛型定义
type FormFieldConfig<
  TFieldValues extends FieldValues,
  TName extends Path<TFieldValues> = Path<TFieldValues>,
> = {
  name: TName
  label: string
  type: FieldType
  required?: boolean
  disabled?: boolean
  placeholder?: string
  // 校验规则,值类型由 TName 自动推断
  validate?: (value: FieldPathValue<TFieldValues, TName>) => string | true
  // 联动依赖:当依赖字段变化时,当前字段重新校验
  deps?: Path<TFieldValues>[]
  // 选项列表(仅 select/multi-select 类型)
  options?: TName extends keyof TFieldValues
    ? Array<{
        label: string
        value: TFieldValues[TName] extends Array<infer U> ? U : TFieldValues[TName]
      }>
    : never
}

// 工具类型:从路径提取值类型
type FieldPathValue<
  T extends FieldValues,
  P extends Path<T>,
> = P extends `${infer K}.${infer Rest}`
  ? K extends keyof T
    ? T[K] extends FieldValues
      ? FieldPathValue<T[K], Rest & Path<T[K]>>
      : never
    : never
  : P extends keyof T
    ? T[P]
    : never

这个泛型的核心设计点:

  1. TName extends Path<TFieldValues>:字段名必须是表单类型的合法路径,拼写错误直接编译报错
  2. FieldPathValue<TFieldValues, TName> :校验函数的参数类型自动匹配字段值类型,validate 回调中访问 value 无需类型断言
  3. deps 联动字段 :用 Path<TFieldValues> 约束依赖字段名,防止引用不存在的字段

模块二:字段路径的递归类型推断

React Hook Form 内部的 Path<T> 类型已经支持嵌套对象和数组的路径展开。但在 AccessGuard 中,我们面对的是更深层的嵌套------条件表达式树。一个策略条件可以无限嵌套:

typescript 复制代码
// AccessGuard 的条件表达式类型(来自系列前面的文章)
type ConditionExpression =
  | { type: "eq"; attribute: string; value: string | number }
  | { type: "neq"; attribute: string; value: string | number }
  | { type: "gt"; attribute: string; value: number }
  | { type: "lt"; attribute: string; value: number }
  | { type: "in"; attribute: string; values: Array<string | number> }
  | { type: "contains"; attribute: string; value: string }
  | { type: "and"; children: ConditionExpression[] }
  | { type: "or"; children: ConditionExpression[] }
  | { type: "not"; child: ConditionExpression }

// 策略表单类型
type PolicyFormValues = {
  name: string
  description?: string
  effect: "allow" | "deny"
  subject: {
    attributeType: "department" | "role" | "clearance"
    operator: "eq" | "neq" | "in" | "gt" | "lt"
    value: string | number
  }
  resource: {
    type: "document" | "api" | "dashboard"
    conditions: ConditionExpression[]
  }
  environment?: {
    timeRange?: { from: string; to: string }
    ipWhitelist?: string[]
  }
}

对于这种深层嵌套类型,React Hook Form 的 FieldPath<T> 可以展开到 resource.conditions.${number}.children.${number}.value 这样的深度路径。但有一个关键限制:Discriminated Union 的路径展开会产生 any

解决方案是用自定义的 DeepFieldPath 类型来处理 Discriminated Union:

typescript 复制代码
// 增强版路径类型,支持 Discriminated Union
type DeepFieldPath<T> =
  T extends Array<infer U>
    ? `${number}` | `${number}.${DeepFieldPath<U>}`
    : T extends object
      ? {
          [K in keyof T & string]:
            | K
            | `${K}.${DeepFieldPath<T[K]>}`
        }[keyof T & string]
      : never

// 对 ConditionExpression 的路径展开
type ConditionPath = DeepFieldPath<ConditionExpression>
// 结果:
// "type" | "attribute" | "value" | "values"
// | "values.${number}"
// | "children" | "children.${number}"
// | "children.${number}.type" | "children.${number}.attribute"
// | "children.${number}.value" | "children.${number}.children"
// | "children.${number}.children.${number}" ...
// | "child" | "child.type" | "child.attribute" ...

递归类型的终止条件很重要。ConditionExpression 是一个有限深度的递归类型(每个节点要么是叶子节点,要么包含子节点数组),TypeScript 的递归深度限制默认为 1000 层,足够覆盖实际场景。

模块三:字段间联动校验的类型约束

AccessGuard 策略表单中最典型的联动场景:主体属性类型决定属性值的合法类型

subject.attributeType"clearance" 时,subject.value 必须是 number;为 "department" 时必须是 string。这种约束需要在类型层面表达:

typescript 复制代码
// 联动值类型的条件映射
type SubjectValueByAttributeType<T extends string> =
  T extends "clearance" ? number :
  T extends "department" | "role" ? string :
  string | number

// 完整的主体条件类型(带联动约束)
type LinkedSubjectCondition = {
  attributeType: "department" | "role" | "clearance"
  operator: "eq" | "neq" | "in" | "gt" | "lt"
  value: SubjectValueByAttributeType<
    LinkedSubjectCondition["attributeType"]
  >
}
// 注意:上面的自引用在 TypeScript 中不允许
// 实际做法是用泛型函数来约束

TypeScript 不支持类型的自引用,所以我们用泛型函数来实现联动约束:

typescript 复制代码
// 联动校验的类型安全实现
function createLinkedValidator<
  TAttributeType extends "department" | "role" | "clearance",
>(
  attributeType: TAttributeType
): (value: SubjectValueByAttributeType<TAttributeType>) => string | true {
  // 根据属性类型返回对应的校验函数
  const validators = {
    department: (v: string) =>
      typeof v === "string" && v.length > 0 ? true : "部门不能为空",
    role: (v: string) =>
      typeof v === "string" && v.length > 0 ? true : "角色不能为空",
    clearance: (v: number) =>
      typeof v === "number" && v >= 1 && v <= 5
        ? true
        : "安全等级必须在 1-5 之间",
  } as const

  return validators[attributeType] as (
    value: SubjectValueByAttributeType<TAttributeType>
  ) => string | true
}

在 React 组件中使用时,通过 useWatch 监听 attributeType 的变化,动态切换校验器:

typescript 复制代码
import { useWatch } from "react-hook-form"

function SubjectConditionField<
  TFieldValues extends PolicyFormValues,
>({ control, register }: SubjectFieldProps<TFieldValues>) {
  const attributeType = useWatch({
    control,
    name: "subject.attributeType",
  })

  // 根据 attributeType 动态推断 value 的类型
  type CurrentValueType = SubjectValueByAttributeType<typeof attributeType>

  return (
    <div>
      <select {...register("subject.attributeType")}>
        <option value="department">部门</option>
        <option value="role">角色</option>
        <option value="clearance">安全等级</option>
      </select>

      {attributeType === "clearance" ? (
        <input
          type="number"
          {...register("subject.value", {
            valueAsNumber: true,
            min: { value: 1, message: "最小值为 1" },
            max: { value: 5, message: "最大值为 5" },
          })}
        />
      ) : (
        <input
          type="text"
          {...register("subject.value", {
            required: "值不能为空",
          })}
        />
      )}
    </div>
  )
}

模块四:Schema → 初始值 → 提交值 三态类型链

这是本篇最核心的设计。表单存在三种状态,每种状态的类型可能不同:

text 复制代码
[三态类型链]
    │
    ├── Schema 类型(Zod 定义)
    │       │
    │       └── 描述"合法值"的完整结构
    │               例: description: z.string().optional()
    │
    ├── 初始值类型(DefaultValues<T>)
    │       │
    │       └── 所有字段必须存在,可选字段可以是 undefined
    │               例: description: string | undefined
    │
    └── 提交值类型(SubmitValues<T>)
            │
            └── 仅包含非 undefined 的已填写字段
                    例: description 不存在(如果用户未填写)

React Hook Form 7.x 的 DefaultValues<T> 类型已经处理了初始值的可选性:

typescript 复制代码
// React Hook Form 的 DefaultValues 类型
type DefaultValues<TFieldValues> =
  TFieldValues extends FieldValues
    ? Partial<TFieldValues>
    : never

但对于提交值,RHF 的 handleSubmit 回调参数直接使用 TFieldValues,不区分可选字段是否已填写。在 AccessGuard 中,我们需要更精确的提交值类型:

typescript 复制代码
// 提交值类型:移除所有 undefined 可选字段
type SubmitValues<T extends FieldValues> = {
  [K in keyof T as T[K] extends undefined ? never : K]:
    T[K] extends Array<infer U>
      ? Array<SubmitValues<U & FieldValues>>
      : T[K] extends object
        ? SubmitValues<T[K] & FieldValues>
        : T[K]
} & {}

// 使用示例
type PolicySubmitValues = SubmitValues<PolicyFormValues>
// 结果:{
//   name: string
//   effect: "allow" | "deny"
//   subject: { attributeType: ...; operator: ...; value: ... }
//   resource: { type: ...; conditions: Array<SubmitValues<ConditionExpression>> }
//   // description 和 environment 被移除(因为是可选的)
// }

在实际的提交处理中,我们用一个泛型包装函数来桥接:

typescript 复制代码
function createSubmitHandler<
  TFieldValues extends FieldValues,
  TSubmit = SubmitValues<TFieldValues>,
>(
  handler: (data: TSubmit) => Promise<void>
): (data: TFieldValues) => Promise<void> {
  return async (data: TFieldValues) => {
    // 运行时过滤掉 undefined 字段
    const cleaned = removeUndefined(data) as TSubmit
    await handler(cleaned)
  }
}

// 运行时辅助:递归移除 undefined 字段
function removeUndefined<T extends Record<string, unknown>>(
  obj: T
): Record<string, unknown> {
  const result: Record<string, unknown> = {}
  for (const [key, value] of Object.entries(obj)) {
    if (value === undefined) continue
    if (Array.isArray(value)) {
      result[key] = value.map((item) =>
        typeof item === "object" && item !== null
          ? removeUndefined(item as Record<string, unknown>)
          : item
      )
    } else if (typeof value === "object" && value !== null) {
      result[key] = removeUndefined(value as Record<string, unknown>)
    } else {
      result[key] = value
    }
  }
  return result
}

三态类型链的完整流转:

text 复制代码
Zod Schema
    │
    └── z.infer<typeof schema>
            │
            ├──→ DefaultValues<FormValues>
            │       │
            │       └── useForm<FormValues>({ defaultValues: ... })
            │
            └──→ SubmitValues<FormValues>
                    │
                    └── createSubmitHandler(data => ...)
                            │
                            └── data 类型自动推断为 SubmitValues<FormValues>

实战落地

实战一:权限策略表单完整实现

下面是 AccessGuard 中一个完整的权限策略创建表单,展示从 Schema 定义到表单渲染的全链路类型安全:

typescript 复制代码
// schema/policy-form.schema.ts
import { z } from "zod"

// 条件表达式的 Zod Schema
const conditionExpressionSchema: z.ZodType<ConditionExpression> = z.lazy(() =>
  z.discriminatedUnion("type", [
    z.object({
      type: z.literal("eq"),
      attribute: z.string().min(1, "属性名不能为空"),
      value: z.union([z.string(), z.number()]),
    }),
    z.object({
      type: z.literal("neq"),
      attribute: z.string().min(1),
      value: z.union([z.string(), z.number()]),
    }),
    z.object({
      type: z.literal("gt"),
      attribute: z.string().min(1),
      value: z.number(),
    }),
    z.object({
      type: z.literal("lt"),
      attribute: z.string().min(1),
      value: z.number(),
    }),
    z.object({
      type: z.literal("in"),
      attribute: z.string().min(1),
      values: z.array(z.union([z.string(), z.number()])),
    }),
    z.object({
      type: z.literal("contains"),
      attribute: z.string().min(1),
      value: z.string(),
    }),
    z.object({
      type: z.literal("and"),
      children: z.array(z.lazy(() => conditionExpressionSchema)),
    }),
    z.object({
      type: z.literal("or"),
      children: z.array(z.lazy(() => conditionExpressionSchema)),
    }),
    z.object({
      type: z.literal("not"),
      child: z.lazy(() => conditionExpressionSchema),
    }),
  ])
)

// 策略表单的完整 Zod Schema
const policyFormSchema = z.object({
  name: z
    .string()
    .min(2, "策略名称至少 2 个字符")
    .max(50, "策略名称不超过 50 个字符"),
  description: z.string().max(200).optional(),
  effect: z.enum(["allow", "deny"]),
  subject: z.object({
    attributeType: z.enum(["department", "role", "clearance"]),
    operator: z.enum(["eq", "neq", "in", "gt", "lt"]),
    value: z.union([z.string(), z.number()]),
  }),
  resource: z.object({
    type: z.enum(["document", "api", "dashboard"]),
    conditions: z.array(conditionExpressionSchema).min(1, "至少一个资源条件"),
  }),
  environment: z
    .object({
      timeRange: z
        .object({
          from: z.string(),
          to: z.string(),
        })
        .optional(),
      ipWhitelist: z.array(z.string().ip()).optional(),
    })
    .optional(),
})

// 从 Schema 推导类型(单一真相源)
type PolicyFormValues = z.infer<typeof policyFormSchema>

export { policyFormSchema, type PolicyFormValues }

表单组件实现:

tsx 复制代码
// components/PolicyForm.tsx
import { useForm, useFieldArray, useWatch } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { policyFormSchema, type PolicyFormValues } from "./schema/policy-form"
import { createSubmitHandler } from "./utils/submit-handler"

function PolicyForm() {
  const form = useForm<PolicyFormValues>({
    resolver: zodResolver(policyFormSchema),
    defaultValues: {
      name: "",
      description: "",
      effect: "allow",
      subject: {
        attributeType: "department",
        operator: "eq",
        value: "",
      },
      resource: {
        type: "document",
        conditions: [
          { type: "eq", attribute: "", value: "" },
        ],
      },
    },
  })

  const {
    register,
    control,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = form

  // 字段数组:资源条件可动态增删
  const {
    fields: conditionFields,
    append,
    remove,
  } = useFieldArray({
    control,
    name: "resource.conditions",
  })

  const onSubmit = createSubmitHandler<PolicyFormValues>(
    async (data) => {
      // data 类型为 SubmitValues<PolicyFormValues>
      // description 如果未填写,不会出现在 data 中
      console.log("提交策略:", data)
      await savePolicy(data)
    }
  )

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {/* 策略基本信息 */}
      <div>
        <label>策略名称 *</label>
        <input {...register("name")} />
        {errors.name && <span>{errors.name.message}</span>}
      </div>

      <div>
        <label>描述</label>
        <textarea {...register("description")} />
        {errors.description && <span>{errors.description.message}</span>}
      </div>

      <div>
        <label>效力</label>
        <select {...register("effect")}>
          <option value="allow">允许</option>
          <option value="deny">拒绝</option>
        </select>
      </div>

      {/* 主体条件(含联动校验) */}
      <SubjectConditionSection form={form} />

      {/* 资源条件(字段数组) */}
      <fieldset>
        <legend>资源条件</legend>
        {conditionFields.map((field, index) => (
          <ConditionRow
            key={field.id}
            index={index}
            register={register}
            control={control}
            errors={errors}
            onRemove={() => remove(index)}
          />
        ))}
        <button
          type="button"
          onClick={() =>
            append({ type: "eq", attribute: "", value: "" })
          }
        >
          添加条件
        </button>
        {errors.resource?.conditions && (
          <span>{errors.resource.conditions.message}</span>
        )}
      </fieldset>

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? "提交中..." : "创建策略"}
      </button>
    </form>
  )
}

主体条件的联动组件:

tsx 复制代码
function SubjectConditionSection({
  form,
}: {
  form: UseFormReturn<PolicyFormValues>
}) {
  const {
    register,
    control,
    formState: { errors },
  } = form

  // 监听 attributeType 变化,动态调整 value 的输入方式
  const attributeType = useWatch({
    control,
    name: "subject.attributeType",
  })

  return (
    <fieldset>
      <legend>主体条件</legend>

      <div>
        <label>属性类型</label>
        <select {...register("subject.attributeType")}>
          <option value="department">部门</option>
          <option value="role">角色</option>
          <option value="clearance">安全等级</option>
        </select>
      </div>

      <div>
        <label>操作符</label>
        <select {...register("subject.operator")}>
          <option value="eq">等于</option>
          <option value="neq">不等于</option>
          <option value="in">在列表中</option>
          {attributeType === "clearance" && (
            <>
              <option value="gt">大于</option>
              <option value="lt">小于</option>
            </>
          )}
        </select>
      </div>

      <div>
        <label>属性值</label>
        {attributeType === "clearance" ? (
          <input
            type="number"
            {...register("subject.value", { valueAsNumber: true })}
            min={1}
            max={5}
          />
        ) : (
          <input
            type="text"
            {...register("subject.value")}
          />
        )}
        {errors.subject?.value && (
          <span>{errors.subject.value.message}</span>
        )}
      </div>
    </fieldset>
  )
}

实战二:递归嵌套表单(条件表达式树)

AccessGuard 的条件表达式是递归结构------and/or 节点可以包含任意深度的子节点。渲染这种递归表单需要递归组件配合递归类型:

tsx 复制代码
import type {
  UseFormRegister,
  Control,
  FieldErrors,
} from "react-hook-form"
import type { PolicyFormValues } from "./schema/policy-form"
import { useFieldArray } from "react-hook-form"

type ConditionRowProps = {
  index: number
  basePath: `resource.conditions.${number}`
  register: UseFormRegister<PolicyFormValues>
  control: Control<PolicyFormValues>
  errors: FieldErrors<PolicyFormValues>
  onRemove: () => void
  depth?: number
}

function ConditionRow({
  index,
  basePath,
  register,
  control,
  errors,
  onRemove,
  depth = 0,
}: ConditionRowProps) {
  // 监听当前条件的类型,决定是否渲染子条件
  const conditionType = useWatch({
    control,
    name: `${basePath}.type` as const,
  })

  const isBranch = conditionType === "and" || conditionType === "or"

  return (
    <div
      style={{
        marginLeft: depth * 24,
        borderLeft: "2px solid #e5e7eb",
        paddingLeft: 16,
      }}
    >
      <div>
        <select {...register(`${basePath}.type` as const)}>
          <option value="eq">等于</option>
          <option value="neq">不等于</option>
          <option value="gt">大于</option>
          <option value="lt">小于</option>
          <option value="in">在列表中</option>
          <option value="contains">包含</option>
          <option value="and">并且(AND)</option>
          <option value="or">或者(OR)</option>
          <option value="not">取反(NOT)</option>
        </select>

        {!isBranch && conditionType !== "not" && (
          <>
            <input
              placeholder="属性名"
              {...register(`${basePath}.attribute` as const)}
            />
            <input
              placeholder="值"
              {...register(`${basePath}.value` as const)}
            />
          </>
        )}

        <button type="button" onClick={onRemove}>
          删除
        </button>
      </div>

      {/* 递归渲染子条件 */}
      {isBranch && (
        <BranchChildren
          basePath={basePath}
          register={register}
          control={control}
          errors={errors}
          depth={depth + 1}
        />
      )}

      {conditionType === "not" && (
        <NotChild
          basePath={basePath}
          register={register}
          control={control}
          errors={errors}
          depth={depth + 1}
        />
      )}
    </div>
  )
}

// 分支节点(and/or)的子条件数组
function BranchChildren({
  basePath,
  register,
  control,
  errors,
  depth,
}: Omit<ConditionRowProps, "index" | "onRemove">) {
  const {
    fields,
    append,
    remove,
  } = useFieldArray({
    control,
    name: `${basePath}.children` as "resource.conditions",
  })

  return (
    <div>
      {fields.map((field, i) => (
        <ConditionRow
          key={field.id}
          index={i}
          basePath={`${basePath}.children.${i}` as `resource.conditions.${number}`}
          register={register}
          control={control}
          errors={errors}
          onRemove={() => remove(i)}
          depth={depth}
        />
      ))}
      <button
        type="button"
        onClick={() =>
          append({ type: "eq", attribute: "", value: "" })
        }
      >
        添加子条件
      </button>
    </div>
  )
}

递归组件的类型安全依赖于前面定义的 ConditionExpression 联合类型。useWatchname 参数通过模板字面量类型约束,保证路径合法。

生产避坑经验

坑一:useFieldArrayfields 类型退化

当数组元素是 Discriminated Union(如 ConditionExpression)时,fields 的元素类型会退化为联合类型的公共部分,丢失判别属性的类型收窄能力。

typescript 复制代码
// 问题:fields[i].type 的类型是 string,不是判别联合
const { fields } = useFieldArray({
  control,
  name: "resource.conditions",
})
// fields[i] 的类型是 ConditionExpression 的公共部分
// 访问 .children 会报错,因为不是所有变体都有 children

解决方案:在渲染时通过类型守卫收窄:

typescript 复制代码
function isBranchCondition(
  condition: ConditionExpression
): condition is
  | Extract<ConditionExpression, { type: "and" }>
  | Extract<ConditionExpression, { type: "or" }>
{
  return condition.type === "and" || condition.type === "or"
}

坑二:Zod z.lazyzodResolver 的循环引用

Zod 的 z.lazy() 用于定义递归类型,但某些版本的 @hookform/resolvers 在处理 z.lazy 嵌套过深时会栈溢出。解决方案是限制递归深度或在提交时额外校验。

坑三:registervalueAsNumber 与条件渲染冲突

subject.attributeType"clearance"(number input)切换到 "department"(text input)时,之前通过 valueAsNumber: true 注册的值会残留在表单状态中,类型为 number 但新字段期望 string。需要用 resetField 在切换时重置:

typescript 复制代码
useEffect(() => {
  // 属性类型变化时,重置 value 字段
  form.resetField("subject.value", {
    defaultValue: attributeType === "clearance" ? 1 : "",
  })
}, [attributeType, form.resetField])

坑四:深层嵌套路径的 TypeScript 性能

当表单类型超过 50 个字段且嵌套超过 4 层时,FieldPath<T> 的展开会导致 IDE 提示变慢(2-5 秒)。解决方案:

  1. 拆分大表单为多个子表单,每个子表单独立 useForm
  2. 对固定结构的嵌套(如条件表达式)使用单独的组件和独立的 useForm
  3. Control<TFieldValues> 传递表单控制权,而非传递整个 UseFormReturn

技术优缺点与适用场景

维度 优势 局限
类型安全 字段路径、值类型、校验参数全链路编译期检查 Discriminated Union 路径推断仍有边界情况
开发体验 IDE 自动补全字段名和值,拼写错误即时提示 深层嵌套类型导致 IDE 响应变慢
维护成本 Schema 单一真相源,修改一处全链路更新 Zod schema + RHF 类型 + 组件代码三层结构
运行时安全 Zod 在提交时兜底校验,防止类型断言错误 z.lazy 递归校验有栈溢出风险
表单复杂度 支持任意深度嵌套、动态数组、联动校验 递归表单组件调试困难

适用场景

  1. 企业级 IAM/权限策略表单:字段多、嵌套深、联动关系复杂,类型安全的投入产出比最高
  2. 配置编辑器:JSON Schema 驱动的动态表单,Schema → TS 类型的自动推导避免手工同步
  3. 多步骤表单向导:每步独立 schema,最终合并提交,类型链贯穿全流程

禁忌场景

  1. 简单登录/搜索表单:3-5 个扁平字段,类型系统的复杂度远超收益
  2. 纯运行态动态表单:字段结构从后端 API 动态返回、无法用静态类型描述时,泛型约束无意义
  3. 对首屏加载敏感的场景:Zod schema + 类型推导增加 bundle 体积(约 15-30KB gzip)

全文总结

本文以 AccessGuard 权限策略表单为实战场景,构建了一套基于 TypeScript 泛型的类型安全表单系统。核心设计思想可浓缩为三点:

  1. Schema 即类型 :用 Zod schema 作为单一真相源,通过 z.infer 自动推导 TypeScript 类型,消除手动维护 FormValues 接口的负担和类型漂移风险
  2. 路径即约束FieldPath<T>FieldPathValue<T, P> 两个递归类型将字段路径从"运行时字符串"提升为"编译期类型约束",拼写错误在 IDE 中即时暴露
  3. 三态类型链:Schema 类型 → DefaultValues 类型 → SubmitValues 类型的完整推导链,保证表单在创建、编辑、提交三种状态下的类型一致性

联动校验通过条件类型 SubjectValueByAttributeType<T> 在编译期约束不同属性类型下的合法值类型,递归表单通过 z.lazy + 递归组件实现任意深度的条件表达式编辑。配合 React Hook Form 7.x 的 FieldPath 递归路径类型和 useFieldArray 动态数组 API,形成了一套从底层类型到上层 UI 的完整解决方案。

本期专栏更新说明

本文为《TypeScript 从入门到精通》专栏第一季(AccessGuard 贯穿案例)持续迭代内容,专栏长期更新类型系统内核、高级类型体操、Compiler API、React 类型集成与生产级 Monorepo 架构,一次订阅,永久持续更新。第一季完结后将开启第二季,以全新贯穿案例重新从入门螺旋。

专栏推荐

参考资料