TypeScript 表单与错误状态建模:字段值、校验结果与提交流程怎样统一语义

HTMLPAGE 团队
17 分钟阅读

表单最难维护的通常不是 input 组件,而是字段值、校验结果和提交流程被拆成一堆布尔变量。本文从状态建模、错误分层和 schema 协作出发,讲清 TypeScript 如何把复杂表单重新组织成可推理系统。

#TypeScript #Form State #Validation #Error Modeling #State Machine

很多表单项目一开始都只是几个字段、几个错误提示,看起来完全不需要认真建模。真正让团队头疼的,往往是表单长大以后:字段之间有联动,部分错误来自本地校验,部分错误来自服务端,提交流程还有草稿、提交中、失败重试和成功回显。到这个阶段,如果状态仍然靠 isSubmittinghasErrorisDirtysubmitSuccess 这类零散布尔值拼出来,复杂度会非常快地失控。

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: true
  • hasErrors: false,但 fieldErrors.email 里仍然有内容
  • isTouched: false,却已经显示服务端错误

这些状态并不是逻辑写得不够严谨,而是建模方式天然允许它们同时出现。只要状态是多个松散布尔值,系统就会允许“不可能状态”存在。可辨识联合或状态机式建模的价值,就在于让这些组合在类型层面先消失。

校验结果要分层:字段错误、表单错误、服务端错误不要混成一团

很多表单一报错,就统一塞到 errorMessage: string 里。短期看很省事,后面会越来越难处理。至少应该区分三类错误:

错误层级典型来源更适合怎么展示
字段错误本地 schema 校验、必填缺失、格式错误挂到对应字段附近
表单错误字段关系冲突、全局校验失败挂在表单顶部或统一区域
服务端错误接口拒绝、重复提交、权限问题顶部反馈 + 恢复建议

如果这三类都混成一个字符串,用户体验会很差,开发侧也很难决定下一步应该重新输入、修复字段还是直接重试。

schema 校验要和表单状态协作,而不是互相重复

表单里最容易出现的另一种浪费,是 schema 定了一套规则,组件里又手写一套字段判断,提交前再来一套 if/else。这样不仅重复,还很容易漂移。

更稳的方式通常是:

  1. schema 负责描述输入合法性。
  2. 表单状态负责决定何时显示错误、何时允许提交。
  3. 提交流程负责决定服务端失败后怎样映射回字段或表单错误。

也就是说,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 能提供的最大帮助,不只是字段类型,而是让值、校验和提交流程都拥有明确模型。只要这三层拆清,再复杂的表单也会更可推理、更容易维护,也更不容易在迭代中长成布尔状态泥球。

本批次专题导航:

本系列导航:

延伸阅读: