TypeScript 静态类型与运行时校验

解释 TypeScript 类型擦除后为何不能验证 API、JSON 或存储数据,以及如何从 unknown 建立可信业务类型。

#type / synthesis #status / evergreen #tech / dev #resource / typescript #resource / javascript

[!info] related notes

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 为判别字段的联合,适合需要降级、记录或继续处理其他记录的场景。
  • inputoutput 可能不同:默认值、转换和 coercion 会让“允许输入什么”和“业务层最终拿到什么”分离。

具体 API 见 Zod 运行时 Schema 校验

BodySense 的真实边界

以流式问诊为例:

Response.body
  -> useSSEProcessor.ts 读取字节并增量解码
  -> processSSELine 取出 data 字段
  -> JSON.parse
  -> StreamEvent 校验(可信边界应在这里建立)
  -> EVENT_MAP 分发
  -> activeTurnReducer.ts 更新状态
  -> React 组件渲染

当前 useSSEProcessor.tsJSON.parse 后直接执行 data as StreamEventconsultationService.ts 在回放事件中也存在相似断言。这是很好的生产级练习点:类型契约已经在 packages/contracts/src/stream-events.ts 中定义,但运行时输入尚不能仅凭声明获得可信身份。

StreamEvent 是较大的可辨识联合,完整 Schema 可以由每个事件分支组成;第一轮实践不必一次覆盖所有事件,可以先严格解析 message.text.deltastream.donestream.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:''、空白和布尔转换可能产生意外业务值。

从阅读到独立实现

  1. 预测:在 processSSELine 中,把 { "type": "message.text.delta", "payload": {} } 断言为 StreamEvent 后,TypeScript 和运行时分别会发生什么?
  2. 模仿:只为 message.text.delta 写一个返回可信事件的解析函数,并覆盖正常值、缺少 delta、错误 seq 三种输入。
  3. 重建:不看参考代码,从需求重新实现 parseStreamEvent(value: unknown) 的三个事件分支。
  4. 迁移:把同样的可信边界迁移到历史事件回放,比较“丢弃坏记录”和“整批失败”的产品取舍。
创建于 2026/7/29 更新于 2026/8/1