TypeScript 静态类型与运行时校验
解释 TypeScript 类型擦除后为何不能验证 API、JSON 或存储数据,以及如何从 unknown 建立可信业务类型。
[!info] related notes
- 收窄工具:TypeScript 联合类型、收窄与类型守卫
- 状态建模:TypeScript 可辨识联合与穷尽检查
- 结构兼容:TypeScript 结构化类型与函数兼容性
- 断言边界:TypeScript 的 as const、satisfies 与类型断言
- Schema 工具:Zod 运行时 Schema 校验
- 跨端契约:前后端 DTO 同步
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:
''、空白和布尔转换可能产生意外业务值。
从阅读到独立实现
- 预测:把
{ "type": "message.text.delta", "payload": {} }直接断言为StreamEvent后,TypeScript 和运行时分别会发生什么? - 模仿:只为一个事件类型写返回可信事件的解析函数,并覆盖正常值、缺少字段、错误字段类型三种输入。
- 重建:不看参考代码,从需求重新实现一个
parseEvent(value: unknown)。 - 迁移:把同样的可信边界迁移到 HTTP response、localStorage 或历史事件回放,并比较“丢弃坏记录”和“整批失败”的产品取舍。