← [ BACK_TO_NOTES ]

TypeScript 静态类型与运行时校验

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

#synthesis #evergreen #dev #typescript #javascript

[!info] related notes

TypeScript 静态类型与运行时校验

[!abstract] 学习目标 读完后应能指出一段数据何时仍是 unknown,选择手写守卫或 Schema 校验,并把验证放在网络、存储或消息协议的入口,而不是组件内部。

心智模型:类型是承诺,验证才是证据

TypeScript 最重要的一条边界可以压缩成一句话:

静态类型是在编译期描述“代码准备怎样使用一个值”,运行时校验才是在边界上证明“这个值现在真的符合契约”。

因此可以把数据可信度想成一个单向过程:

外部世界
API / JSON / localStorage / URL / 消息流
        ↓
      unknown
        ↓  runtime evidence
守卫 / Schema / parser
        ↓
   trusted domain type
        ↓
业务逻辑 / reducer / component

as T、泛型参数和函数返回类型只能改变编译器的判断,不能凭空产生这一步 runtime evidence。

面试或代码评审时可以这样表达:

“我会把网络和存储边界的数据先视为 unknown。TypeScript 类型在运行时会被擦除,所以 response.json() as User 只是静态承诺,不是验证;只有经过 type guard、Schema 或 parser 成功解析后的值,才进入可信业务类型。”

本章七个概念放在一张图里

Structural typing
“形状够不够?”
        ↓
Type erasure
“类型运行时不存在”
        ↓
unknown + narrowing
“未知值先拿证据再使用”
        ↓
Schema / parser
“边界上建立可信类型”
        ↓
Discriminated union
“把有限状态的合法组合写进类型”
        ↓
Exhaustive narrowing + never
“新增状态时让遗漏分支变成编译错误”

其中一个常见误区是把“HTTP client 写了 Promise<T>”误认为服务器已经返回了 T。泛型只能告诉调用方代码希望得到什么,不能验证网络 payload。

类型承诺为什么不是运行时证据

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 就结束,而是把“不可信输入”转换成“业务层允许使用的值”,或者明确失败。

unknown 为什么是更好的边界类型

any 的语义近似“相信我,不要检查”;unknown 的语义则是“我还不知道它是什么”。因此 unknown 会迫使调用方先收窄:

function readName(value: unknown) {
  if (
    typeof value === 'object' &&
    value !== null &&
    'name' in value &&
    typeof value.name === 'string'
  ) {
    return value.name.toUpperCase();
  }
}

仅仅证明“对象里有 name”还不够,value.name 本身仍可能是 unknown;需要继续证明它是 string。这体现了一个很稳的原则:每跨过一层不确定性,都需要相应证据。

简单契约先用类型守卫

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 校验。

可辨识联合与穷尽检查承担什么职责

运行时校验解决“外部数据到底符不符合契约”;可辨识联合则解决“进入系统以后,有限状态之间哪些组合是合法的”。

type RequestState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error };

判别字段让控制流自动收窄;配合 never 的 exhaustive check,可以让新增 variant 后漏改 switch 变成编译期错误。noImplicitReturns 只能证明返回路径是否完整,不能替代联合类型的穷尽性证明。

BodySense 的真实边界

BodySense 当前的流事件契约已经把“不可信网络 JSON”和“可信 StreamEvent”分开:

网络 / 流式文本
  -> JSON.parse
  -> parseStreamEvent(value: unknown)
  -> 对 version / channel / type / payload / ids 等做运行时检查
  -> StreamEvent
  -> reducer / UI

核心实现位于 packages/contracts/src/stream-event-parser.ts,类型契约位于 packages/contracts/src/stream-events.ts。这比直接 as StreamEvent 多出的关键一步,就是运行时证据。

同样的判断也适用于普通 HTTP 数据:如果某个 helper 只是 expectJson<T> 后把 JSON 断言为 T,那么它仍然没有证明 payload 结构;TypeScript 只是在后续代码里把它当成 T 使用。

与跨语言契约的关系

共享 TypeScript 类型只能保护 TypeScript 消费者。Go、Python 和线上请求还需要:

  • Go DTO、Python Pydantic 模型等语言内运行时模型
  • 共享 Schema 或黄金 fixture
  • round-trip / parity 测试
  • 协议版本与兼容策略

“三端字段长得一样”不等于契约不会漂移;必须有可执行的验证证据。

常见失败

  • JSON.parse(raw) as T:只有断言,没有验证。
  • 给 HTTP helper 写 <T>:只形成静态 API,不自动验证网络 payload。
  • 校验顶层是对象后就返回:嵌套字段仍然不可信。
  • Schema 执行了转换,却继续使用原始输入:应使用解析后的输出。
  • 在每个组件重复校验:应把校验收敛到网络、存储或消息适配器。
  • 对所有错误都静默忽略:至少要区分可跳过记录、协议错误和连接错误。
  • 对用户输入滥用 coercion:''、空白和布尔转换可能产生意外业务值。

从阅读到独立实现

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

键盘导航

j / k / ↑ / ↓
选择对象
Enter / o
打开选择
/ 或 Ctrl+K
全站搜索
i / f / 1–4
筛选当前列表
g → h / l / p / t / a
首页 / 笔记 / 项目 / 工具 / About
r → 1–9
打开所选对象的关联
u
返回上一页
Esc
关闭浮层 / 取消命令;输入框内清空文本