很多表单项目一开始都只是几个字段、几个错误提示,看起来完全不需要认真建模。真正让团队头疼的,往往是表单长大以后:字段之间有联动,部分错误来自本地校验,部分错误来自服务端,提交流程还有草稿、提交中、失败重试和成功回显。到这个阶段,如果状态仍然靠 isSubmitting、hasError、isDirty、submitSuccess 这类零散布尔值拼出来,复杂度会非常快地失控。
TypeScript 在表单里的最大价值,并不是“给字段补 interface”这么简单,而是帮助团队把表单看成一个真正的状态系统:字段值是一层,字段校验是一层,提交流程又是一层。只要这些语义被拆开并且正确关联,复杂表单就会明显更可读;如果继续混在一起,后面再多逻辑都会变成布尔变量互相打架。
先把三类状态分开:值、校验、提交流程不是一回事
最常见的表单混乱,来自把这三类东西堆在同一个对象里:
- 字段当前值
- 字段错误和表单级错误
- 提交生命周期状态
更稳的做法通常是显式拆层:
type FormValues = {
email: string
company: string
agreePolicy: boolean
}
type FieldErrors = Partial<Record<keyof FormValues, string[]>>
type SubmitState =
| { kind: 'idle' }
| { kind: 'submitting' }
| { kind: 'success'; receiptId: string }
| { kind: 'failure'; message: string }
这类拆法的意义不只是“类型更清楚”,而是让团队终于能回答:当前问题到底出在输入值、校验失败,还是提交流程失败。
表单最容易出现的坏味道,是布尔状态彼此组合出不可能情况
比如:
isSubmitting: true,但同时submitSuccess: truehasErrors: false,但fieldErrors.email里仍然有内容isTouched: false,却已经显示服务端错误
这些状态并不是逻辑写得不够严谨,而是建模方式天然允许它们同时出现。只要状态是多个松散布尔值,系统就会允许“不可能状态”存在。可辨识联合或状态机式建模的价值,就在于让这些组合在类型层面先消失。
校验结果要分层:字段错误、表单错误、服务端错误不要混成一团
很多表单一报错,就统一塞到 errorMessage: string 里。短期看很省事,后面会越来越难处理。至少应该区分三类错误:
| 错误层级 | 典型来源 | 更适合怎么展示 |
|---|---|---|
| 字段错误 | 本地 schema 校验、必填缺失、格式错误 | 挂到对应字段附近 |
| 表单错误 | 字段关系冲突、全局校验失败 | 挂在表单顶部或统一区域 |
| 服务端错误 | 接口拒绝、重复提交、权限问题 | 顶部反馈 + 恢复建议 |
如果这三类都混成一个字符串,用户体验会很差,开发侧也很难决定下一步应该重新输入、修复字段还是直接重试。
schema 校验要和表单状态协作,而不是互相重复
表单里最容易出现的另一种浪费,是 schema 定了一套规则,组件里又手写一套字段判断,提交前再来一套 if/else。这样不仅重复,还很容易漂移。
更稳的方式通常是:
- schema 负责描述输入合法性。
- 表单状态负责决定何时显示错误、何时允许提交。
- 提交流程负责决定服务端失败后怎样映射回字段或表单错误。
也就是说,schema 不应该替代表单状态管理;它提供规则,状态系统决定这些规则在什么时机进入 UI。
一个常见失败案例:后端错误直接塞回字段,结果语义越来越乱
某团队为了“统一显示”,把所有服务端错误都尽量塞回字段级错误。开始看起来用户能更快定位问题,后面却出现很多奇怪情况:
- 限流错误被挂在某个字段上
- 账号权限不足也显示成字段提示
- 表单全局冲突被拆成多个字段各自报一点
这类问题的根因是错误层级被硬压平了。不是所有错误都应该落到字段上。TypeScript 在这里的作用,就是帮助你把错误语义层级先建模清楚,再决定 UI 如何映射。
提交流程最好显式建成状态机,而不是靠多个布尔拼凑
表单提交流程最适合用有限状态来表示,因为它天然是互斥阶段:
- 空闲
- 提交中
- 成功
- 失败
一旦用了可辨识联合,很多条件判断会明显更清晰:
function renderSubmitMessage(state: SubmitState): string | null {
switch (state.kind) {
case 'idle':
return null
case 'submitting':
return '正在提交...'
case 'success':
return `提交成功:${state.receiptId}`
case 'failure':
return state.message
}
}
这比维护多个布尔开关更稳,因为成功和失败不可能再同时出现。
一份表单状态建模检查表
- 值、错误和提交流程是否已经拆成三层语义。
- 字段错误、表单错误、服务端错误是否有明确层级区分。
- schema 是否负责规则,状态系统是否负责显示时机。
- 提交流程是否用互斥状态建模,而不是多个布尔标志拼凑。
- 是否还能出现“成功和失败同时为 true”之类不可能状态。
总结
复杂表单真正难的不是字段多,而是状态语义没有被组织起来。TypeScript 能提供的最大帮助,不只是字段类型,而是让值、校验和提交流程都拥有明确模型。只要这三层拆清,再复杂的表单也会更可推理、更容易维护,也更不容易在迭代中长成布尔状态泥球。
本批次专题导航:
- 工程边界:TypeScript 项目引用与 tsconfig 分层、TypeScript Monorepo 依赖边界治理、TypeScript 类型检查性能优化
- 协议协作:TypeScript 公共 API 设计、TypeScript 运行时校验与静态类型协作、TypeScript 与 OpenAPI 契约协同
- 落地复用:TypeScript 设计模式实战、TypeScript 测试数据构建
- 状态建模:TypeScript 事件系统建模、TypeScript 表单与错误状态建模
本系列导航:
- 如果你还没把输入边界统一到 schema,先读 TypeScript 运行时校验与静态类型协作
- 若你要把测试场景和表单状态一起收拢,再看 TypeScript 测试数据构建
- 如果你还在处理跨模块事件和状态同步,可读 TypeScript 事件系统建模
延伸阅读:


