TypeScript 静态类型与运行时校验
解释 TypeScript 类型擦除后为何不能验证 API、JSON 或存储数据,以及如何从 unknown 建立可信业务类型。
[!info] related notes
- 收窄工具:TypeScript 联合类型、收窄与类型守卫
- 断言边界:TypeScript 的 as const、satisfies 与类型断言
- Schema 工具:Zod 运行时 Schema 校验
- 跨端契约:前后端 DTO 同步
TypeScript 静态类型与运行时校验
[!abstract] 学习目标 读完后应能指出一段数据何时仍是
unknown,选择手写守卫或 Schema 校验,并把验证放在网络、存储或消息协议的入口,而不是组件内部。
类型承诺为什么不是运行时证据
TypeScript 类型用于编译期分析,生成 JavaScript 时会被擦除。API 响应、JSON.parse、localStorage、URL 参数和用户输入都发生在运行时,因此不能因为函数写了返回类型就自动可信。
async function loadUser(): Promise<User> {
const response = await fetch('/users/me');
return response.json();
}
这里的 Promise<User> 是开发者对调用者作出的承诺,不是服务器数据已经满足 User 的证明。类似地:
const event = JSON.parse(raw) as StreamEvent;
as StreamEvent 只改变编译器如何看待变量,不会在 JavaScript 中生成任何字段检查。
可信边界的数据流
外部字节
-> 文本解码
-> JSON.parse 得到 unknown
-> 结构校验与必要转换
-> 可信领域类型
-> reducer / service / component
校验函数的职责不是返回 true 就结束,而是把“不可信输入”转换成“业务层允许使用的值”,或者明确失败。
简单契约先用类型守卫
interface User {
id: string;
displayName: string;
}
function parseUser(value: unknown): User {
if (typeof value !== 'object' || value === null) {
throw new Error('user must be an object');
}
if (!('id' in value) || typeof value.id !== 'string') {
throw new Error('user.id must be a string');
}
if (!('displayName' in value) || typeof value.displayName !== 'string') {
throw new Error('user.displayName must be a string');
}
return { id: value.id, displayName: value.displayName };
}
手写守卫适合字段少、规则稳定的边界。它的优势是没有额外依赖;缺点是嵌套结构、联合类型和错误路径会很快变得繁琐。
复杂契约使用 Schema
Zod、Valibot 或 JSON Schema 等工具可以同时描述结构、执行校验并生成结构化错误。核心原则仍然相同:
unknown -> schema.parse / safeParse -> validated output
parse:失败时抛错,适合失败就应终止当前操作的边界。safeParse:返回以success为判别字段的联合,适合需要降级、记录或继续处理其他记录的场景。input与output可能不同:默认值、转换和 coercion 会让“允许输入什么”和“业务层最终拿到什么”分离。
具体 API 见 Zod 运行时 Schema 校验。
BodySense 的真实边界
以流式问诊为例:
Response.body
-> useSSEProcessor.ts 读取字节并增量解码
-> processSSELine 取出 data 字段
-> JSON.parse
-> StreamEvent 校验(可信边界应在这里建立)
-> EVENT_MAP 分发
-> activeTurnReducer.ts 更新状态
-> React 组件渲染
当前 useSSEProcessor.ts 在 JSON.parse 后直接执行 data as StreamEvent;consultationService.ts 在回放事件中也存在相似断言。这是很好的生产级练习点:类型契约已经在 packages/contracts/src/stream-events.ts 中定义,但运行时输入尚不能仅凭声明获得可信身份。
StreamEvent 是较大的可辨识联合,完整 Schema 可以由每个事件分支组成;第一轮实践不必一次覆盖所有事件,可以先严格解析 message.text.delta、stream.done 和 stream.error 三种事件,再扩展。
[!warning] 当前依赖边界 BodySense 的锁文件中存在 Zod 的传递依赖,但 Web 应用没有把 Zod 声明为直接依赖。生产代码若决定直接导入 Zod,应先把它加入
apps/web/package.json,不能依赖传递依赖碰巧存在。
与跨语言契约的关系
共享 TypeScript 类型只能保护 TypeScript 消费者。Go、Python 和线上请求还需要:
- Go DTO、Python Pydantic 模型等语言内运行时模型
- 共享 Schema 或黄金 fixture
- round-trip / parity 测试
- 协议版本与兼容策略
“三端字段长得一样”不等于契约不会漂移;必须有可执行的验证证据。
常见失败
JSON.parse(raw) as T:只有断言,没有验证。- 校验顶层是对象后就返回:嵌套字段仍然不可信。
- Schema 执行了转换,却继续使用原始输入:应使用解析后的输出。
- 在每个组件重复校验:应把校验收敛到网络、存储或消息适配器。
- 对所有错误都静默忽略:至少要区分可跳过记录、协议错误和连接错误。
- 对用户输入滥用 coercion:
''、空白和布尔转换可能产生意外业务值。
从阅读到独立实现
- 预测:在
processSSELine中,把{ "type": "message.text.delta", "payload": {} }断言为StreamEvent后,TypeScript 和运行时分别会发生什么? - 模仿:只为
message.text.delta写一个返回可信事件的解析函数,并覆盖正常值、缺少delta、错误seq三种输入。 - 重建:不看参考代码,从需求重新实现
parseStreamEvent(value: unknown)的三个事件分支。 - 迁移:把同样的可信边界迁移到历史事件回放,比较“丢弃坏记录”和“整批失败”的产品取舍。