我在生产中看到的大多数昂贵的错误都不在一个层内。它位于各层之间。后端改了一个字段的名字前端不知道。银行栏变成可选的,API 继续将其视为强制栏。合同存在于某人的头脑中、一份过时的文件中,或者不存在于任何地方。
端到端类型安全是通过使类型信息跨越所有系统边界(从[数据库0]到呈现到屏幕的组件)来堵住这些漏洞的想法。我想将其作为一项架构决策进行讨论,其中没有人将其实际收益和成本放在幻灯片上。
问题不在于层,而在于它们之间的接缝
典型的系统至少具有三个边界,在这些边界中类型会丢失。在银行和后端之间。在后端和 API 之间。 API 和前端之间。每条接缝都是需要传播有关模具形状的知识的点,传统上是通过惯例、文档或信仰来传播的。
当传输失败时,编译器无能为力,因为它一次只能看到接缝的一侧。前端相信一种方式,后端产生另一种方式,并且两者都能愉快地编译。该错误仅在运行时出现,当双方相遇并发现他们说不同的语言时。
端到端类型安全的中心论点是,这些接缝应该由编译器检查,而不是希望。如果后端更改了某些内容,前端应在提交之前立即在其自己的编辑器中停止编译。集成错误不再作为一个类别存在,因为它在发生之前就被捕获了。
第一针:ORM 和类型化查询构建器
一切都从银行开始。这就是数据所在的地方,也是关于其形状的真相应该显现的地方。 ORM 和类型化查询构建器(例如 Prisma 和 Drizzle)的存在使得表的类型可以被 [TypeScript1´ 识别,而不是通过每个查询强制发现。
Prisma 采用以其自身架构为中心的方法,从中生成完全类型化的客户端。您用专用语言描述表和关系,Prisma 生成了解每个字段、每种类型、每种关系的代码。查询受到保护:请求不存在的列将成为编译错误。
《细雨》基于另一种哲学。您无需使用外部模式和代码生成,而是在 [TypeScript2] 本身中定义表并编写看起来像 SQL 的查询,从而始终保持类型。它离银行更近,您和查询之间的魔力层也更少。对于那些重视生成的 SQL 的控制和可预测性的人来说,它通常是最舒服的选择。
哲学上的差异在决策中很重要,但两种情况下的收益是相同的:从这里开始,数据类型来自数据库,而不是来自有人会忘记更新的手写界面。
第二条接缝:类型化 API
输入银行可以解决三分之一的问题。查询知道它返回什么,但是当数据序列化为 JSON 并通过网络发送时,这种知识就消失了。另一方面,前端收到未输入的文本,必须再次猜测。
另一方面重建类型的经典方法是从 API 合约生成类型。您可以使用 OpenAPI 或 [GraphQL3´ 等格式来描述 API,并且工具会根据该合约生成客户端类型。它有效,并且在多语言系统中运行良好,其中前端和后端是不同的语言或单独的团队。契约是明确事实的源泉,双方都从中衍生。
tRPC 从不同的、更激进的路径解决同样的问题。当前端和后端都在同一个存储库中的[TypeScript4]时,它消除了中间契约。服务器上定义的过程类型由客户端直接推断,无需生成代码,也无需单独的模式。你在前端调用一个函数,TypeScript 已经知道参数和返回值,因为它实际上与跨境服务器的类型相同。
效果是 API 和前端之间的接缝消失了。无需维护同步,因为没有两个描述。它在服务器上发生了变化,在客户端上立即崩溃了。正是相同的单一事实来源逻辑使得[Zod5数据验证如此有效,现在应用于网络边界。
真正的收获不是减少打字
这就是理解技术的人和理解其价值的人的区别点。支持端到端类型安全的薄弱论点是自动完成。它很漂亮,很舒服,但它是化妆品。任何以打字效率为卖点的人都卖错了部分。
真正的收获是消除了一整类错误。存在于各层之间的集成错误将不再存在,因为编译器会在运行时之前捕获它们。你没有更快地纠正它们,你只是不写它们。这是类别的变化,而不是程度的变化。
第二个同样被低估的好处是安全重构。在类型跨越边界的系统中,重命名数据库中的字段会将一波编译错误一直传播到最后使用它的组件。您可以像地图一样跟踪错误,当项目再次编译时,重构就完成且正确了。如果没有这个网络,重命名一个字段是一种没有人愿意做的勇敢行为,这就是代码腐烂的原因:团队避免触碰他们害怕破坏的东西。这是[高级 TypeScript 实践]的中心主题,端到端类型安全将其带到了架构极限。
没有人在幻灯片上做出权衡
所有这些都不是免费的,无论谁决定架构都需要正面考虑成本。首先是耦合。 tRPC 直接类型推断之所以有效,是因为客户端和服务器共享代码,这通常需要双方都有一个 monorepo 和一个 [TypeScript7? 堆栈。这种将前端和后端联系在一起的方式可能正是您在小型集成团队中想要的,也可能正是您在团队需要独立发展时不想要的。
二是锁定。以 tRPC、Prisma 或 Drizzle 为基础的构建就是押注于这些工具及其生态系统。稍后迁移的成本很高,今天保护您的抽象概念明天也会阻碍您。因此,在更可移植且与语言无关的契约式 API 和更高效但耦合性更强的推理 API 之间进行选择是一个战略决策,而不是技术细节。
第三是学习曲线和模式成本。模式建模、理解推理和诊断类型错误确实非常复杂,有时又长又令人生畏。资深团队吸收速度快;队形中的团队会感受到摩擦。这些决策在 [monorepos with TypeScript8] 中变得更加密集,其中共享类型是最大的优势,也是构建复杂性的最大来源。
我的建议是务实的:如果你有一个端到端的[TypeScript9堆栈,一个重视迭代速度并容忍耦合的团队,直接推理会带来不成比例的回报。如果您有独立的团队、多种语言或使用您的 API 的外部客户端,则更喜欢显式契约并支付类型生成的成本以换取可移植性。
在选择工具之前,先画出你的边界,并在每个边界中决定你愿意用多少耦合来换取安全。这场早期的对话比任何框架基准测试都更有价值。
另请阅读
- [Monorepos 与 TypeScript:什么时候重要以及需要事先考虑什么10
- [高级 TypeScript 实践:浅度使用与成熟使用的区别11
- [使用 Zod 进行数据验证:为什么 TypeScript 类型还不够12
- [应用架构:基本原理和模式13
- [可扩展的软件架构:如何构建可增长的系统14
- [应用后端:架构、技术和最佳实践15