TypeScript 运行时校验与静态类型协作:schema、解析与错误模型的落地方法

HTMLPAGE 团队
18 分钟阅读

TypeScript 只能约束编译期,但真实系统每天都在接收不可信输入。本文从 schema 边界、解析策略和错误模型出发,讲清运行时校验如何和静态类型协作,而不是彼此重复或彼此缺位。

#TypeScript #Runtime Validation #Schema #Error Modeling #Type Safety

很多团队把“有 TypeScript”误认为“输入已经安全”。但 TypeScript 只约束你在编译期写下的假设,它并不会替你检查网络响应、表单提交、环境变量、第三方 Webhook 或历史数据库脏数据。真正进入系统边界的值,如果没有经过运行时校验,依旧是未知数。

这也是为什么成熟的 TypeScript 系统一定会同时谈两件事:静态类型负责让内部协作更稳,运行时校验负责让外部输入先被证明可信。两者不是替代关系,更不是重复关系,而是位于不同边界上的两层防线。

先划清边界:什么时候静态类型有用,什么时候根本帮不上忙

静态类型最擅长的是“你自己写的代码之间如何保持一致”。比如:

  • service 层返回什么结构,页面层怎样正确消费。
  • 一个工具函数要求什么参数,调用方是否满足。
  • 状态机有哪些合法状态,分支是否穷举。

但只要值来自系统外部,静态类型就会立刻失去证明能力。下面这类写法看起来有类型,实际上只是把风险藏起来:

const raw = await fetch('/api/user').then((res) => res.json())
const user = raw as User

这里的 as User 不是校验,只是告诉编译器“请相信我”。如果后端少返回一个字段,或者把 role 从字符串改成数组,运行时问题依旧会原样落到业务逻辑里。

schema 的职责,不是帮你多写一份类型,而是在边界把未知值变成已知值

运行时校验最稳的落点通常是系统边界:

  • API 请求入站
  • API 响应入站
  • 表单提交
  • 环境变量加载
  • 队列消息或 Webhook 消费

在这些地方,值进入系统之前应该先从 unknown 变成“已通过校验的具体类型”。无论你用 Zod、Valibot、io-ts 还是手写解析器,关键都不是库名,而是这个动作必须发生在边界,而不是散落在业务代码深处。

import { z } from 'zod'

const UserSchema = z.object({
  id: z.string(),
  name: z.string(),
  role: z.enum(['admin', 'editor', 'viewer'])
})

type User = z.infer<typeof UserSchema>

function parseUser(input: unknown): User {
  return UserSchema.parse(input)
}

这里最重要的不是 infer 自动生成了类型,而是 parseUser 形成了边界:在这个函数外,调用方拿到的 User 才是真正可信的用户对象。

解析策略要先决定:失败时是拒绝、回退,还是部分接受

运行时校验的难点并不在“会不会校验”,而在“校验失败后系统怎么处理”。不同场景的策略完全不同:

  • 登录态、支付、权限等高风险输入,通常应该直接拒绝。
  • 页面配置、埋点字段、可选推荐位等低风险输入,可以考虑降级或回退默认值。
  • 批量导入、历史数据修复这类场景,可能要接受“部分成功 + 错误清单”。

如果团队只讨论 schema 长什么样,却不先决定失败策略,最后常见结果是所有异常都被统一扔成 500,既不利于定位,也不利于用户恢复。

错误模型要分层,不要把所有失败都塞进一种 Error

一个可维护的错误模型,至少要区分三类问题:

  1. 校验错误:输入结构不合法,字段缺失、类型不对、枚举值越界。
  2. 领域错误:结构合法,但业务条件不满足,比如订单状态不允许取消。
  3. 基础设施错误:网络超时、存储失败、依赖服务异常。

这三类错误如果混成一种“请求失败”,调用方就很难决定后续动作。更实用的做法是给它们不同语义:

type ValidationError = {
  kind: 'validation'
  fieldErrors: Record<string, string[]>
}

type DomainError = {
  kind: 'domain'
  code: 'ORDER_LOCKED' | 'PERMISSION_DENIED'
  message: string
}

type InfraError = {
  kind: 'infra'
  retryable: boolean
  message: string
}

这样前端、服务层和日志系统拿到错误后,才知道应该提示用户、引导重试,还是直接升级告警。

一个常见失败案例:schema 写得很全,但解析发生得太晚

有些项目确实引入了 schema 库,也为接口定义了漂亮的对象结构,但校验动作并没有发生在边界,而是拖到业务逻辑里才零散调用。这样会带来两个问题:

  • 脏数据已经沿着多个函数继续传递,校验失败时很难定位真正入口。
  • 不同调用点各自决定默认值和容错方式,系统行为开始分叉。

这种架构里,schema 更像“到处都可能补一刀的工具函数”,而不是统一入口。问题不在库,而在边界没有被明确建立。

最稳的协作方式,是“边界 parse 一次,内部只用可信类型”

团队协作里最省认知成本的模式通常是:

  • 系统入口把值视为 unknown
  • 边界 parse 一次,把不合法输入挡在门外。
  • 进入业务层后,不再到处写 typeofArray.isArray、字段存在性猜测。

这会让业务代码明显更干净,因为内部逻辑处理的是“已证明可信的类型”,而不是“也许是这个结构”的值。

一份可以直接套用的边界检查清单

  • 这个值是否来自系统外部,而不是当前仓库内部。
  • 校验发生在入口,还是拖到业务逻辑深处才补。
  • 校验失败后,系统是拒绝、降级还是部分接受。
  • 错误是否区分校验、领域和基础设施三层语义。
  • 校验通过后的值,是否真的以具体类型传入后续逻辑,而不是继续保留 any 或宽泛对象。

如果这些问题都能答清,运行时校验就不再是“补一个库”,而是系统边界治理的一部分。

总结

TypeScript 让内部协作更稳,运行时校验让外部输入真正可信。前者负责表达假设,后者负责验证假设。只要把 schema 放到边界,把错误模型做分层,把校验后的值作为唯一可信输入,你的类型系统才不会停留在编辑器里的漂亮幻觉。

本批次专题导航:

本系列导航:

延伸阅读: