【高级】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)
这个表单有三个核心挑战:
- 字段路径类型安全 :
register("subject.attributeType")中的路径字符串必须与表单结构匹配,拼写错误应在编译期暴露 - 值类型依赖路径 :不同路径对应不同值类型------
subject.attributeType是字符串联合类型,resource.conditions是对象数组,environment.ipWhitelist是string[] | undefined - 联动校验的类型约束 :当
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
这个泛型的核心设计点:
TName extends Path<TFieldValues>:字段名必须是表单类型的合法路径,拼写错误直接编译报错FieldPathValue<TFieldValues, TName>:校验函数的参数类型自动匹配字段值类型,validate回调中访问value无需类型断言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 联合类型。useWatch 的 name 参数通过模板字面量类型约束,保证路径合法。
生产避坑经验
坑一:useFieldArray 的 fields 类型退化
当数组元素是 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.lazy 与 zodResolver 的循环引用
Zod 的 z.lazy() 用于定义递归类型,但某些版本的 @hookform/resolvers 在处理 z.lazy 嵌套过深时会栈溢出。解决方案是限制递归深度或在提交时额外校验。
坑三:register 的 valueAsNumber 与条件渲染冲突
当 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 秒)。解决方案:
- 拆分大表单为多个子表单,每个子表单独立
useForm - 对固定结构的嵌套(如条件表达式)使用单独的组件和独立的
useForm - 用
Control<TFieldValues>传递表单控制权,而非传递整个UseFormReturn
技术优缺点与适用场景
| 维度 | 优势 | 局限 |
|---|---|---|
| 类型安全 | 字段路径、值类型、校验参数全链路编译期检查 | Discriminated Union 路径推断仍有边界情况 |
| 开发体验 | IDE 自动补全字段名和值,拼写错误即时提示 | 深层嵌套类型导致 IDE 响应变慢 |
| 维护成本 | Schema 单一真相源,修改一处全链路更新 | Zod schema + RHF 类型 + 组件代码三层结构 |
| 运行时安全 | Zod 在提交时兜底校验,防止类型断言错误 | z.lazy 递归校验有栈溢出风险 |
| 表单复杂度 | 支持任意深度嵌套、动态数组、联动校验 | 递归表单组件调试困难 |
适用场景:
- 企业级 IAM/权限策略表单:字段多、嵌套深、联动关系复杂,类型安全的投入产出比最高
- 配置编辑器:JSON Schema 驱动的动态表单,Schema → TS 类型的自动推导避免手工同步
- 多步骤表单向导:每步独立 schema,最终合并提交,类型链贯穿全流程
禁忌场景:
- 简单登录/搜索表单:3-5 个扁平字段,类型系统的复杂度远超收益
- 纯运行态动态表单:字段结构从后端 API 动态返回、无法用静态类型描述时,泛型约束无意义
- 对首屏加载敏感的场景:Zod schema + 类型推导增加 bundle 体积(约 15-30KB gzip)
全文总结
本文以 AccessGuard 权限策略表单为实战场景,构建了一套基于 TypeScript 泛型的类型安全表单系统。核心设计思想可浓缩为三点:
- Schema 即类型 :用 Zod schema 作为单一真相源,通过
z.infer自动推导 TypeScript 类型,消除手动维护FormValues接口的负担和类型漂移风险 - 路径即约束 :
FieldPath<T>和FieldPathValue<T, P>两个递归类型将字段路径从"运行时字符串"提升为"编译期类型约束",拼写错误在 IDE 中即时暴露 - 三态类型链:Schema 类型 → DefaultValues 类型 → SubmitValues 类型的完整推导链,保证表单在创建、编辑、提交三种状态下的类型一致性
联动校验通过条件类型 SubjectValueByAttributeType<T> 在编译期约束不同属性类型下的合法值类型,递归表单通过 z.lazy + 递归组件实现任意深度的条件表达式编辑。配合 React Hook Form 7.x 的 FieldPath 递归路径类型和 useFieldArray 动态数组 API,形成了一套从底层类型到上层 UI 的完整解决方案。
本期专栏更新说明
本文为《TypeScript 从入门到精通》专栏第一季(AccessGuard 贯穿案例)持续迭代内容,专栏长期更新类型系统内核、高级类型体操、Compiler API、React 类型集成与生产级 Monorepo 架构,一次订阅,永久持续更新。第一季完结后将开启第二季,以全新贯穿案例重新从入门螺旋。