很多团队把“有 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
一个可维护的错误模型,至少要区分三类问题:
- 校验错误:输入结构不合法,字段缺失、类型不对、枚举值越界。
- 领域错误:结构合法,但业务条件不满足,比如订单状态不允许取消。
- 基础设施错误:网络超时、存储失败、依赖服务异常。
这三类错误如果混成一种“请求失败”,调用方就很难决定后续动作。更实用的做法是给它们不同语义:
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 一次,把不合法输入挡在门外。
- 进入业务层后,不再到处写
typeof、Array.isArray、字段存在性猜测。
这会让业务代码明显更干净,因为内部逻辑处理的是“已证明可信的类型”,而不是“也许是这个结构”的值。
一份可以直接套用的边界检查清单
- 这个值是否来自系统外部,而不是当前仓库内部。
- 校验发生在入口,还是拖到业务逻辑深处才补。
- 校验失败后,系统是拒绝、降级还是部分接受。
- 错误是否区分校验、领域和基础设施三层语义。
- 校验通过后的值,是否真的以具体类型传入后续逻辑,而不是继续保留
any或宽泛对象。
如果这些问题都能答清,运行时校验就不再是“补一个库”,而是系统边界治理的一部分。
总结
TypeScript 让内部协作更稳,运行时校验让外部输入真正可信。前者负责表达假设,后者负责验证假设。只要把 schema 放到边界,把错误模型做分层,把校验后的值作为唯一可信输入,你的类型系统才不会停留在编辑器里的漂亮幻觉。
本批次专题导航:
- 工程边界:TypeScript 项目引用与 tsconfig 分层、TypeScript Monorepo 依赖边界治理、TypeScript 类型检查性能优化
- 协议协作:TypeScript 公共 API 设计、TypeScript 运行时校验与静态类型协作、TypeScript 与 OpenAPI 契约协同
- 落地复用:TypeScript 设计模式实战、TypeScript 测试数据构建
- 状态建模:TypeScript 事件系统建模、TypeScript 表单与错误状态建模
本系列导航:
- 如果你正在处理对外契约,下一篇建议读 TypeScript 与 OpenAPI 契约协同
- 若你要把错误继续收回业务状态,可接着看 TypeScript 表单与错误状态建模
- 如果你还需要统一系统事件边界,再看 TypeScript 事件系统建模
延伸阅读:


