您的TypeScriptAPI在发布时可能看起来很坚固。路由编译,前端导入了共享类型,编辑器给每个人带来了干净的绿色感觉,这通常意味着“安全发布”。
然后后端改变了一个响应字段,一个可空值出现在没有人预期的位置,或者一个移动客户端一直在调用旧的数据包形状。那样的大多数 API在TypeScript中 工作中断。不是语法。是漂移。
目录
- 为什么类型化API在发布后会失败?以及如何预防它?
- 正确构建TypeScriptAPI项目的方法
- 在边界上设计DTO和验证输入
- 生成和消费一个完全类型化的API客户端
- 实际有帮助的错误处理、测试和可观察性
- 以自信和控制的方式将产品交付到生产环境
为什么在发布后,类型化的API会失败,如何预防
通常在普通发布过程中,类型化的API会出现问题。团队之一重命名了响应字段。另一个添加了一个可空分支来支持部分迁移。较旧的客户端仍然发送了以前的负载,因为移动端更新落后于web端。TypeScript仍然在更新了本地类型的每个仓库中编译。生产环境中的契约已经是错误的。
这种失败有一个名字:契约漂移

TypeScript使API的工作更加舒适,但也使弱契约更容易被过度信任。共享接口、路由泛型和类型化 fetch wrapper 在开发过程中提供帮助。它们并不能证明 JSON 在网络中穿越后,仍然与那些类型匹配,第二次或第十次发布后。
生产环境中有效的规则很简单。
如果未经验证的 JSON 可以直接流入应用逻辑,TypeScript 类型描述的是意图,而不是现实。
解决方案不是关于巧妙的类型操纵,而是关于真理的位置:
- 在边界处进行验证。 解析请求体、参数、头部和下游服务响应之前,整个 code 都不应接触它们。
- 将 DTO 映射到域模型。 将运输形状与业务对象分开,以便 API 的变化不会渗透到整个代码库中。
- 从合同生成类型。 OpenAPI、JSON Schema 或以schema为首的框架为客户端和服务器提供了一个共享的真理来源。
- 将破坏性更改视为公共事件。 如果一个字段的形状发生变化,版本它并且像任何其他外部合同变化一样进行沟通。
DTO映射是团队最常忽略的部分。它最初看起来很冗余。经过几次发布后,它成为防止数据和前端屏幕传播不一致的层。 string | null 在边界上进行小的转换步骤比后期的广泛重构更便宜。
类型化API也会失败,因为错误契约通常是后来才考虑的。成功的载荷得到关注。失败的载荷变成抛出异常时序列化的那一天的任何内容。客户端然后在未被设计的形状上构建重试逻辑、用户消息和监控。结果是相同的问题以不同的形式出现。
漂移。 API 的版本策略 __CAPGO_KEEP_0__版本控制策略
使这些更改在它们到达消费者之前变得可见。
正确的 TypeScript API 项目搭建方式
构建您的TypeScriptAPI项目的正确方式。一个类型化的API通常在第一天看起来很干净。六个月后,一条路接受未经检查的输入,另一条路读取原始 process.env,并且第三个返回一个客户端没有编码的形状。 架子很少一次性崩溃。 它为正常的功能工作留下了足够的空间,使合同漂移可以悄悄地进入。
从一个使合同难以绕过的项目形状开始。

选择与团队形状匹配的框架。
对于TypeScript中的API,第一个框架决策并不是关于语法,而是关于合同纪律将存活的地方。
- Express 适合那些想要最小抽象并且已经熟悉中间件模型的团队。 它不会干涉,直到每个路由都发明了自己的验证、错误形状和响应约定。
- Fastify 适合小型和中型后端团队。 其插件系统清晰,推动了schema工作更接近路由层,这有助于保持运行时行为与类型一致。
- Nest 适合拥有多个贡献者、共享模块和明确的所有权边界的大型代码库。 但是,这个成本是真实的,如果服务本身很小的话。
我通常避免购买比团队会使用的框架更多的东西。 一个小型服务,使用Fastify、验证库和生成的合同类型,经常比一个更重的堆栈,具有不一致约定叠加在顶部的服务更容易在重构时存活下来。
使用保护边界的文件夹结构
文件夹名称相对于导入压力而言不那么重要。如果路由可以直接访问数据库模型,或者服务可以直接将ORM实体返回给客户端,那么骨架已经在邀请漂移。
生产环境中通常能坚持的布局通常将传输相关的东西与应用相关的东西分开:
src/routes仅用于HTTP编程src/schemas仅用于请求和响应模式src/dto仅用于传输类型和映射codesrc/services仅用于用例和编排src/domain仅用于应该超越任何单个端点的业务模型src/clients仅用于下游集成src/errors仅用于共享错误类型和缩小帮助src/config仅用于启动时配置解析
That src/dto 层次结构不是无用的忙活。它给了API一个吸收外部变化而不泄露它们到域逻辑或通过不相关端点的机会。
配置值应得到同样的处理。启动时解析环境变量一次,快速失败无效值,并将类型化的配置对象导出到应用的其余部分。那些一直在处理程序内部阅读的团队通常会以branchy运行行为结果,TypeScript无法帮助他们。这篇关于 process.env 环境配置 的指南是一个很好的参考,如果您需要标准化该模式。 在添加功能之前,紧固编译器
生产__CAPGO_KEEP_0__应该使写入不安全__CAPGO_KEEP_1__的行为不那么容易。
一个生产环境的API应该使写入不安全的code变得不方便。
启用
strict如果团队可以承受额外的纪律useUnknownInCatchVariables如果团队可以承受额外的纪律noUncheckedIndexedAccess除非Node、测试、打包和工具都以相同的方式解析它们,否则不使用路径别名- 环境配置指南
- 分离
build,typecheck, 并在CI中运行lint脚本
一个弱 tsconfig 让它积累在无人注意的情况下。一个严格的会将不匹配转化为可见的工作,避免它们成为生产行为。
lint规则也会有所帮助,尤其是那些反对 any,悬浮的承诺,和意外的公共契约模块导出。这些都不会替代运行时验证,但会减少契约错误可以躲藏的地方。
另一个重要的基建选择是早期就决定你的OpenAPI规范会来自哪里,并且保持这个决定与路由层接近。一些团队会从code-first方案中生成它。其他团队会先从规范中生成服务器stub和类型。两种方法都可以工作。失败的是把规范当作一个无人检查的副产品,直到第一个发布之后。
在初始基建之后,比较类型化的契约在普通的请求-响应服务之外的行为会有所帮助。关于流和事件丰富系统的Streamkap Flink TypeScript指南 对于处理流或事件丰富系统的团队来说是有用的,契约漂移会在更长的管道中出现,而不是仅仅在HTTP处理器中。 设计DTO和边界输入验证
在边界处设计 DTO 和验证输入
A typed API usually looks correct on day one. Six months later, the bugs show up at the boundary. A mobile client still sends an old field. A partner omits a property your frontend assumed was always present. A refactor exposes an internal ORM column in a public response. TypeScript did its job inside the codebase. The contract still drifted.
DTO 设计很重要。它不是关于让请求体看起来整洁的。它是关于在第一次发布后保持公共类型的诚实。
公共接口和内部模型不应相同
A DTO 描述了穿过线的内容。一个 域模型 描述了应用程序需要做的实际工作内容。合并这些关注点会在早期节省几行代码,但在后期会产生昂贵的耦合。

如果您的路由接收到这个:
type CreateOrderRequestDto = {
customerId: string
items: Array<{ sku: string; quantity: number }>
note?: string | null
}
您的服务层应该接受更窄、更干净的内容,例如一个 OrderDraft 具有规范化字符串、验证的数量和默认值的应用程序
边界通常需要这些步骤:
- 解析入站载荷
- 验证形状和字段级约束
- 将 DTO 映射到域对象
- 在域对象上运行业务逻辑
- 将结果映射到响应 DTO
- 在发送之前验证出站响应
第六步经常被跳过。它也是捕获私有字段、 nullable 值和意外的 schema 变更的步骤,尤其是在重构时。
在业务逻辑接触数据之前验证
编译时类型不会验证网络中的 JSON。它们也不会保护您免受另一个服务返回仍然满足 unknown 并在运行时破坏您的假设。
For API work in TypeScript, Zod is a common choice because it parses at runtime and infers types for the rest of the code. Valibot, io-ts, and similar libraries can work too. The library matters less than the rule. Untrusted data gets parsed before anything else uses it.
一个能在重构中生存的模式看起来像这样:
- Inbound schema 拒绝 malformed 请求数据
- Dependency schema 验证来自第三方 API 和内部服务的响应
- Outbound schema 验证您的 API 即将发布的响应
很多类型的 API 在上线后都会失败。团队验证请求,跳过下游响应的验证,然后会惊讶于为什么一个供应商字段重命名会导致生产事故。
我的实用规则是:原始 JSON 只到路由层。
映射 code 不是浪费。它是漂移变得可见的地方
团队经常抵制 DTO 映射,因为它感觉重复。我在生产环境中见过相反的情况。一个薄的映射层是合同变化变得明显、可审查和本地的地方
例如:
- transport 允许
note?: string | null - 领域模型可能存储
note: string与""作为默认值 - 响应DTO可能省略
note完全丢弃当它是空的
这三个是针对三个不同的受众的三个不同的真理。将它们视为一个共享的接口会掩盖差异,直到客户端崩溃。
一个 webhook 会让这一点更加清晰,因为消费者可能会将您的载荷形状保留多年。如果您的团队正在处理这个问题,这个 webhook 载荷设计示例 是有用的伴侣。
共享类型只有在真实来源是明确的时才有帮助
将后端接口复制到前端是漂移的延迟版本。共享包可以帮助,但只有当类型是故意公开的时才有效。
一个能在更大的代码库中持久的设置看起来像这样:
- 定义公共请求和响应模式,分离它们与持久性模型
- 从公共模式生成 OpenAPI,或者从 OpenAPI 生成服务器类型
- 将生成的契约类型保留在处理器和客户端附近
- 将域类型和 ORM 模型保留在内部
- 在兼容性很重要的情况下故意版本化公共 DTO
这也与 Azure __CAPGO_KEEP_0__ 团队的 TypeScript 设计指南一致 TypeScript design guidelines from the Azure SDK team可维护的界限看起来很无聊
好的版本不那么聪明
前端之前信任
像它是真理一样,后端直接返回 ORM 对象,一个共享的接口试图代表每个层次。现在,每个界限解析数据,DTOs 保持狭窄,域模型保持内部,生成的类型覆盖公共契约,映射 __CAPGO_KEEP_0__ 显示更改 fetch().json() 现在,每个界限解析数据,DTOs 保持狭窄,域模型保持内部,生成的类型覆盖公共契约,映射 code 显示更改
它增加了繁文缛节。它还为您提供了一个地方来审查在调用者找到它之前的漂移。
生成和消费一个完全类型化的API客户端
一个类型化的客户端在发布日看起来通常已经完成了。三个月后,一些端点开始返回可空字段,另一个端点添加了游标分页,一个移动应用程序固定了旧版本的合同。TypeScript类型仍然编译。调用者仍然会崩溃。
这是客户端层的工作。它应该在第一版发布后保持发布的合同的真实性,而不仅仅是让编辑自动完成看起来好。
选择您的类型客户端策略
客户端形状应该与API的实际复杂性相匹配,而不是团队的偏好。
| 方法 | 最佳选择 | 权衡利弊 |
|---|---|---|
| 手动编写的fetch包装器 | 适用于小型应用程序、不寻常的认证流程、快速迭代 | 快速开始。易于在不同调用点上分散 |
| OpenAPI code generation | 简洁的REST API,稳定的模式 | 强大的基础。需要帮助来实现自定义认证、流式传输或非标准分页 |
| SDK-style类型化客户端 | 多个团队的平台、公共API、长期的集成 | 最高的维护成本。最佳的消费者体验是当API是一个产品 |
手动编写的客户端适用于小的surface
一个自定义的 fetch 包装是合理的选择,当API是内部的、surface面积小、或传输行为比模式生成更重要时
我仍然使用这种方法来开发管理工具和早期服务 any 失败模式是漂移。一个团队在包装中添加了重试规则。另一个绕过了它。第三个复制了响应类型到前端并扩展到
在第一个不匹配后。您最终会得到“类型化”的调用,它们不再代表服务器返回的内容。
- API很小且内部
- 由于合同经常变化,重新生成code会产生噪音
- 自定义传输行为占据了大部分工作
- 你愿意在客户端保留运行时解析,而不仅仅是TypeScript注解
最后一点很重要 response.json() 即使函数签名表明如此,运行时也会返回未知数据
OpenAPI生成是实际的默认值
对于稳定的REST API,生成的类型提供了最佳的维护与安全性比率。它们移除了大量的重复类型编写,并且使合同变化在pull请求中可见
经过重构的模式很简单。从公共合同中生成,保持生成层薄,添加一个小的包装器,根据你的消费者需要更好的便利性 OpenAPI的TypeScript生成工作流 适合这个模型
一个有用的分离看起来像这样
- 生成的 code 拥有请求和响应形状
- 一个薄 SDK wrapper 拥有 auth 注入、重试和分页助手
- 运行时验证仍然发生在服务器边界和任何未受信任输入重新进入系统的任何地方
- DTO 映射保持明确,以便内部模型更改不会泄露到客户端契约
这种混合方法使生成的 code 变得乏味,这是好的。乏味的 code 更容易重新生成、审查和替换。
在应用程序 code 触摸它们之前将生成的客户端包装起来
生成的函数通常太原始了,无法在整个代码库中广泛使用。它们暴露了传输细节,所有调用者都必须重新学习。
一个薄的 wrapper 给你一个地方来保持政策一致:
- 附加默认头和请求 ID
- 规范错误形状
- 暴露分页作为迭代器或辅助方法
- 支持每个请求的 auth 覆盖以支持多租户场景
- 保留生成的请求和响应类型,而不是手动重写它们
例如,应用程序code应该调用 client.orders.listAll() 或 client.orders.list({ cursor })例如,应用程序__CAPGO_KEEP_0__应该调用
SDK風格的客户端在API是一种产品时才有意义
例如,应用程序__CAPGO_KEEP_0__应该调用
或
client.orders.list()例如,应用程序__CAPGO_KEEP_0__应该调用client.files.stream()或- 例如,应用程序__CAPGO_KEEP_0__应该调用
- 或
例如,应用程序__CAPGO_KEEP_0__应该调用
A完全类型的客户端并不是终点。终点是客户端,其类型仍然与现实相符,而不管API如何演进,因为从公共接口开始生成,运行时验证保护边界,DTO映射防止内部变化泄露到外部。
实际有帮助的错误处理、测试和可观察性
大多数TypeScriptAPI示例过于平和。请求成功,JSON与接口匹配,失败变成 throw new Error("something went wrong")生产环境永远不会这么客气。
第一个修复是机械的。在TypeScript中 捕获的值应该被视为 unknown然后在读取之前缩小 message, stack或响应属性。专家指导也建议自定义错误类,保留原始失败并在边界上验证,规范非Error抛出并附加请求上下文以实现可观察性( causeTypeScript错误处理指南TypeScript 错误处理指南).

TypeScript错误处理指南
不安全的catch块仍然常见:
try {
await client.orders.create(input)
} catch (error) {
logger.error(error.message)
}
这假设太多了。 error 可能不是一个 Error 完全没有。
一个更安全的模式:
try {
await client.orders.create(input)
} catch (error: unknown) {
if (error instanceof Error) {
logger.error({ message: error.message, stack: error.stack })
throw new OrderSyncError("Order sync failed", { cause: error })
}
logger.error({ error })
throw new OrderSyncError("Order sync failed", { cause: new Error("Non-Error thrown") })
}
这看起来略微更重一些。它在第三方SDK、JSON解析失败或意外抛出时,能够更好地应对失败。
只有当错误是暂时的时才重试
第二个重大改进是错误分类。关于TypeScript SDK 和 API 操作的指导原则,达成了一致的规则: 重试暂时性失败,如网络错误或HTTP 429 和 503 响应,提前验证,保留错误上下文,并避免对业务规则失败的重试. 同样的指导原则也建议 Promise.all 对于快速失败的并行工作和 Promise.allSettled 当部分成功是可接受的时(SDK 错误处理模式).
我喜欢三个桶:
- 验证错误 意味着请求在离开你的进程之前是错误的。
- 暂时错误 可能在使用退避策略后成功重试。
- 永久错误 反映了业务规则、权限或缺失资源,应该直接暴露。
分类驱动的更好 code 比使用通用的“失败重试”辅助函数会更好。
字段规则: 重试属于运输不确定性,而不是域不一致。
可观察性应该解释失败,而不是仅仅记录它们
没有上下文的日志不是可观察性。对于在 TypeScript 中的 API,在请求跨越边界处附加一个关联 ID、路由名称、请求元数据和规范化错误形状。
一个有用的基线:
- 关联 ID 将一个入站请求与下游调用关联起来
- 结构化日志 存储字段,而不是文本块
- 边界日志 捕获解析失败与业务异常分开
- 警报 基于错误类别和路由,而不是仅仅基于状态 code 的流量
如果您的移动或客户端应用程序消费这些 API,更新可观察性也很重要。一个在发布层中的实际选项是 Capgo,提供了为在Capacitor和Electron环境中发送和跟踪实时更新的类型API。这种功能在客户端修复协议需要控制发布和每个版本的可见性,而不是另一次盲目等待应用商店时很有用。对于正在紧缩整个反馈循环的团队,这个指南与 应用可观察性 测试协议,而不是仅仅测试实现
单元测试无法捕捉到漂移。添加漂移发生的地方的测试
边界验证测试:
- 将错误输入喂入模式并断言失败形状 协议测试:
- 确认实际HTTP响应与发布的协议相符 类型错误断言:
- 验证暂时性和永久性失败正确归一化 客户端集成测试:
- app可观察性 确保生成或包装的客户端能够解析真实的响应。
一个强大的类型API的测试套件不仅仅证明了code路径。它证明了您的契约仍然在说真话。
以自信和控制的方式将产品交付
质量的发布来自于可重复的循环,而不是英雄主义。
在TypeScript pipeline中,一个可靠的API通常具有以下几个关键点:在CI中进行schema检查,生成的工件进行类型检查,合并之前进行契约差异审查,部署路径可以在客户端人口未准备好时减速或回滚。
支撑发布的循环
我喜欢将生产检查清单保持在足够短的长度,以便团队遵循它:
- 在契约漂移时,CI失败: 如果OpenAPI发生变化,生成的类型和客户端必须在同一个变更中更新。
- 共享版本的契约故意地: 公共DTO包需要发布纪律,而不是随意的重构。
- 按频道或队列进行发布: 不要一次性让所有消费者面对一个破坏性的集成变更。
- 保持回滚简单: 在简体中文中,翻译为: 恢复合同、客户端或Web包应该是运作中的枯燥事。
同时迁移基础设施和部署流程的团队,可以参考本指南。 云计算迁移 在 TypeScript 中的 API 是一个有用的规划参考,因为 API 可靠性在平台转换时经常会下降,而不仅仅是在 code 变化时。
控制与正确性一样重要
最终的生产习惯是通过版本号来追踪。您需要知道哪个客户端版本正在调用哪个合同,哪些发布已经成功采用,以及在发布后哪里出现了故障。尤其是对于移动和边缘分布式的消费者,他们并不是同时更新。
如果您的堆栈包含 Capacitor 或 Electron,live update 工具可以减少修复合同错误和将修复推送到用户手中的时间差。重要的是“更快的更新”不是抽象概念,而是让用户尽快获得修复的能力。 基于频道的发布、回滚保护和版本级观察性 修订合约修复以保持控制。
类型API保持健康,需要schema、运行时验证、客户端生成以及发布操作之间的相互强化。如果缺少其中一层,其他层会尝试弥补,但效果不佳。
Capgo 为团队提供了快速发布 Capacitor 和 Electron 应用程序的类型安全方式,来修复 Web 包,控制发布渠道,监控版本的采用和失败情况。如果您的 API 合约修复也需要快速到达客户端,而不需要等待商店审查,请访问 Capgo.