首页 / 文章 / 利用样本推断类型与Zod捕捉沉默的API契约漂移

利用样本推断类型与Zod捕捉沉默的API契约漂移

为什么为第三方 API 手动编写的 TypeScript 类型会过时,如何从实际响应中推断类型和 Zod 模式能起到帮助作用,以及快照差异如何揭示类型偏移。

1565 词

第三方 API 会在不发通知的情况下改变结构,而 TypeScript 无法察觉,因为你所定义的类型描述的是当初编写时响应的内容,而非当前的实际内容。本文将阐述这种问题为何会发生,为什么从多个真实响应中自动生成类型比手动输入更有效,以及真正能保护你的方法是如何将新响应与保存的快照进行比对。你还将了解这种方法在 OpenAPI 和契约测试中的定位,以及它的局限性。

字段重命名如何逃过检测

设想一个与支付服务提供商集成的前端系统。某天,该提供商返回的响应中的一个字段从 user_id 变成了 userId。既没有变更记录,也没有相关公告或版本更新。很可能是提供商那边的工程师整理了不一致的命名,测试通过后便将此更改发布了出去。

在消费端没有任何异常抛出,而这恰恰就是问题所在。代码仍然尝试读取response.user_id,TypeScript也允许这样做,因为几个月前是根据一个已不再符合实际情况的Postman示例手动定义了接口类型。该接口仍声明存在user_id字段,但在运行时其值实际上为undefined。在两周的时间里,有三条代码路径悄悄将undefined值写入金额字段中,直到有一份支持工单最终暴露了这一漏洞。既没有警报触发,构建过程也没有出现错误提示,应用程序就这样毫无异常地继续执行错误的操作。

那些长期使用外部API的团队几乎总会遇到类似的问题。

薄弱环节在于类型信息的来源

这里的问题不在 TypeScript 本身,而在于类型的来源。接口通常被视作源自某种权威依据,比如架构规范、合同或唯一的事实来源。但实际上,很多接口只是由某人手动抄录的一个示例响应。这个接口随后被复制到其他多个文件中并被视为既定事实,直到出现问题才会有人再次查看它。

真正的契约就是当前生产环境中 API 返回的内容。它存在于你无法控制的服务器上,且可以在未经你同意的情况下被更改。手写的类型只是已经过去的某个时刻的快照,编译器根本无从知晓这一点。

还存在一个更根本的差距。TypeScript 类型在编译时就会消失,因此运行时根本不会进行任何检查。如果数据结构发生变化,只有通过边界处的运行时验证——比如使用 Zod 模式解析响应——才能将隐性的 undefined 状态立即转化为可见的错误。

从多个实际响应中推断类型

这里最有效的办法并非使用更高级的 TypeScript 或更巧妙的泛型,而是一个机械化的流程:获取 API 实际返回的响应,从中生成类型,一旦实际数据与类型不再匹配就能立即得到警报。

输入内容应为真实的 JSON 响应,而非文档或模式定义。通过这些响应,工具可以推断出对应的 TypeScript 类型以及等价的 Zod 模式。使用多个样本的重要性比想象中更高——一个响应只能展示有效数据的格式,而三到四个响应则能揭示哪些字段确实是可选的、哪些字段有时会为 null,以及哪些数组元素的结构并不一致。仅使用一个样本往往会导致因信息缺失而产生误导。

下面的示例提供了同一资源的两个响应,一个包含 user_id,另一个包含 userId,并展示了从这两个响应中推断出的 TypeScript 类型与 Zod 模式:

// paste these two responses in...
[
  {
    "user_id": "pot_00009exampleP0tOxWb",
    "name": "Wedding Fund",
    "balance": 550100,
    "currency": "GBP",
    "created": "2025-11-09T12:30:53.695Z",
    "updated": "2025-02-26T07:12:04.925Z"
  },
  {
    "userId": "pot_00009exampleP0tOxWb",
    "name": "Wedding Fund",
    "balance": 550,
    "currency": "EUR",
    "created": "2025-11-09T12:30:53.695Z",
    "updated": "2025-03-26T07:12:04.925Z"
  }
]

// ...get this out typescript
type Root = {
  user_id?: string
  name: string
  balance: number
  currency: string
  created: string
  updated: string
  userId?: string
}[]

// or ... get this out zod
import { z } from 'zod'

const Root = z.array(z.object({
  user_id: z.string().optional(),
  name: z.string(),
  balance: z.number(),
  currency: z.string(),
  created: z.string(),
  updated: z.string(),
  userId: z.string().optional(),
}))

仔细查看合并后的结果。由于每个名称仅出现在一个样本中,user_iduserId都变成了可选字段。从技术上讲这是正确的,但这也掩盖了字段重命名的事实:读取任一字段的代码仍会进行类型检查,而既不包含这两个字段的响应也能通过Zod模式验证。这些样本还暗示了一个类型推断永远无法捕捉到的问题:balance的值从550100降至550,同时currency也发生了变化,这可能意味着正在在小货币单位和大货币单位之间切换。在这两种情况下,推断出的类型都是number。类型推断只能告诉你数据的结构,却无法揭示其含义。

许多代码生成工具在此处就停止了。将无类型数据转换为有类型数据固然有用,但并不能解决数据漂移的问题。

快照和差异对比能捕捉到变化

更重要的步骤发生在生成之后。一旦从实际响应中提取出类型信息,该响应就可以被保存为快照。每次从同一个接口获取新的样本时,都会将其与快照进行对比,从而精确地列出所有变化——无论是字段名称的改变、原本为普通string类型的值现在变为可为空,还是嵌套对象中出现了新的键。这样就不会出现“某处出了问题”这种模糊的描述,而是能清楚地看到具体的结构差异。

正是这种对比区分了类型生成器与漂移检测器。代码生成能让一无所有的状态变为带类型的代码;而漂移检测则能确保 user_id 更名为 userId 的情况不会在生产环境中悄无声息地持续数周。在上面的示例中,快照差异报告会显示“移除了 user_id,添加了 userId”,而不会让这两个字段悄悄都变为可选属性。

将生产环境的数据负载保留在你的机器上

要检测真正的漂移现象,需要真实数据;合成测试数据无法揭示你关心的变化。因此,隐私保护便成为一项设计要求。实际响应中可能包含客户信息,若将其粘贴到会上传至第三方服务器的网页表单中,就会带来新的数据处理风险。用于此目的的工具应在本地运行,例如完全在浏览器标签页中运行,或作为你自己的代码仓库中的脚本运行,这样测试数据就不会离开你的环境。

这种方法的适用场景与局限性

基于样本的推理结合漂移检测并不能替代 OpenAPI 或 Pact 这类契约测试工具。如果你同时拥有服务提供方和消费方,并且能够在源头强制实施数据结构规范,那就这么做——这是更理想的长期解决方案。

这种技术针对的是更为常见且不太理想的情况:你使用着自己无法控制的 API,其文档已经过时或根本不存在,而且由于没有相应的规范或没人信任现有规范,因此也无法根据 OpenAPI 规范生成客户端。这种情况多见于与支付处理商、其他团队开发的内部服务以及第三方供应商 API 的集成中。在这种环境下,实际的响应才是唯一的真实依据,因此你的类型定义就应该以此为基准来构建。

将范围限定在较小范围内。只需处理 JSON,排除 TypeScript 和 Zod,再加上漂移检测即可满足需求。试图同时处理 XML、protobuf 以及各种模式边界情况,会令原本精准的工具变得模糊不清。如需更全面地了解那些可能损害前端性能的契约设计问题,请参阅那些会破坏前端稳定性的常见 API 契约错误

实际的首次测试方法

进行此类测试的最佳场景是那些已经出现过隐性结构变化的集成系统。选取同一接口的旧响应数据与最新响应数据,通过推理功能进行对比分析,并查看哪些部分被标记出来。在差异对比中看到真实的历史变化,比任何理论阐述都更有说服力。

核心要点

  • 针对第三方 API 的手写接口只是过去的快照,TypeScript 无法判断它们何时会过时。
  • 通过多个实际响应来推断类型和 Zod 模式,因为多个样本能揭示出单个示例所隐藏的可选字段、可空字段以及不一致的字段。
  • 在 API 边界处于运行时验证响应,这样当结构发生变化时会产生明显错误,而不会生成 undefined 值。
  • 将响应存储为快照,并用新样本与它们进行对比;仅靠合并后的推断结果就可能导致字段重命名被误认为是两个可选字段。
  • 将生产环境中的数据包保留在本地,且在能够控制 API 两端的情况下,优先使用 OpenAPI 或契约测试。

相关阅读

  • 有效的JSON,失效的契约:针对有效载荷回归问题的分层检测方法 — 了解如何识别那些能被正确解析的JSON有效载荷中的回归问题:语义差异分析、聚焦式的JSON Schema设计、在Node中实现的业务规则断言,以及生成类型的局限性。
  • 前端团队适用的HTTP QUERY方法:带请求体的安全读取方式 — 了解在复杂过滤场景下为何HTTP QUERY方法优于GET和POST,如何使用fetch调用该方法,以及实现它所需的支持CORS、缓存和基础设施的条件。