添加第二个中间件破坏了我们的 TypeScript 类型

Lobsters Hottest 工具

摘要

来自 Inngest 的一篇博客文章,详细说明了添加第二个中间件如何因类型约束检查中的漏洞而损坏 TypeScript 类型,并解释了涉及可选属性和条件类型的根本原因。

<p><a href="https://lobste.rs/s/opbq6q/adding_second_middleware_broke_our">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/07/13 21:56

# 添加第二个中间件搞坏了我们的 TypeScript 类型 来源:https://www.inngest.com/blog/adding-a-second-middleware-broke-our-typescript-types 在翻阅 `inngest-js` 的公开 issue 时,我发现了一个相当令人惊讶的问题——**多个中间件会破坏 TS 类型定义**。 > *当我向 Inngest 客户端传递两个中间件时,`step.run` 的返回类型会坍缩为 `{}`。只用一个中间件时一切正常。* 删除其中任何一个中间件,错误就消失了。实际的中间件代码本身也不重要,两个空操作的中间件依然会可靠地引发问题。不知何故,**数量本身**才是破坏类型的元凶。很明显,这里有些可疑的事情正在发生,于是我穿上了侦探服,开始调查。 ## 两个不过是一个执行两次 一个关键背景是:当 Inngest 函数运行时,每个 `step.run` (https://www.inngest.com/docs/reference/typescript/v4/functions/step-run?ref=blog-adding-a-second-middleware-broke-our-typescript-types) 的结果会被序列化为 JSON 并存储,这样该步骤就可以在重试时被记忆化 (https://www.inngest.com/docs/learn/inngest-steps?ref=blog-adding-a-second-middleware-broke-our-typescript-types)。你的函数返回一个 `Date`,但重放时回来的却是字符串。 中间件 (https://www.inngest.com/docs/features/middleware?ref=blog-adding-a-second-middleware-broke-our-typescript-types) 可以转换步骤输出,因此每个中间件都带有一个静态的输出转换。默认转换是 `Jsonify`,这些转换会组合在一起,所以两个中间件意味着 `Jsonify<Jsonify<T>>`。在运行时这显然没问题:序列化已经序列化的数据是空操作。类型应该以同样的方式保持幂等性...对吧?然而事实却是这样的: 删除 `label?: string`,`Twice` 就完美无缺了。整个失败就卡在**一个可选属性**上。嗯? ## 一个与记录相关的惯用做法 `Jsonify` 必须丢弃那些无法存活于序列化 (https://www.inngest.com/docs/reference/typescript/v4/middleware/serialization?ref=blog-adding-a-second-middleware-broke-our-typescript-types) 的属性。比如函数、symbol 和 `undefined`。 “根据值的类型过滤对象的键”的标准惯用做法是这样的:将每个属性映射到**它自己的名称**(如果该值可序列化),否则映射为 `never`,然后通过 `[keyof T]` 把所有值再读出来。 对于 `{ a: string; b: () => void }`,映射类型为 `{ a: "a"; b: never }`,读回来得到 `"a" | never`,`never` 从联合类型中消失,只剩下 `"a"`。再将其传入 `Pick` 就完成了。你可能在自己的代码中也这样做过。 ## `undefined` 如何变成键 使用 `[Key in keyof T]` 编写的映射类型会保留每个属性的修饰符,包括 `?`。所以对于我们的 widget 的 media 元素,中间对象是: ```ts { media: "media" } ``` 而读取一个**可选**属性的值,TypeScript 会在结果中包含 `undefined`。从该对象中读出所有值,你会得到: ```ts "media" | undefined ``` 在键列表中看到 `undefined` 感觉**不对劲**吧?它不应该是个键。但编译器对此完全接受。 ## 那个漏洞 这正是这个 bug 如此难以发现的原因。被污染的联合类型被传入 `Pick`: `Pick` 要求 `K extends keyof T`,通常这个约束是有效的。如果你自己写这个,编译器会当场拒绝: ```ts type Bad = Pick<{ a: string }, undefined>; // 错误 ``` 然而在 `JsonifyObject` 内部,同样的 `Pick` 是针对泛型 `T` 编写的: ```ts type JsonifyObject<T> = Pick<T, FilterJsonableKeys<T>>; ``` 这就是那个漏洞。编译器在代码编写处检查约束,而不是每次使用时都重新检查。在定义位置,`T` 仍是抽象的,因此可选性不会从中泄漏出 `undefined`,也就是说检查通过了。稍后,当 `T` **确实**被填入一个真实类型时,编译器只是在展开一个已经批准的定义。它不会回过头来再针对具体类型重新检查约束。`Pick` 会为联合类型中的每个成员取一个属性,并容忍其中的 `undefined`。 最终输出的类型只是**半残**的。属性访问正常,可赋值性正常,悬停显示也正常,所以所有**普通**的交互都告诉你一切没问题。但它的键集合确实包含了 `undefined`。直到我让编译器承认这一点,我才真正相信了。 一个畸形对象类型被悄悄地制造出来,并且在任何**不直接触碰其键**的使用中都与健康类型无法区分。这就是为什么**一个**中间件从未失败,并且已有测试全都通过的原因。 ## 坍缩 所有 `Jsonify` 的对象机制都以相同的方式开始:迭代 `keyof T`。在第二次应用时,其中一个“键”是 `undefined`,因此编译器最终会计算 `T[undefined]`。你在自己的代码顶层写这个,会得到一个漂亮的红错。但在泛型实例化的深层,TypeScript 不会报告它。相反,它会替换为内部的错误类型并继续执行。 这个错误类型就是编译器的 `NaN`:它碰到的一切都会变成它。键过滤器返回的是错误类型而非键的联合类型,`Pick` 没有有效键时产生 `{}`,最终输出的另一端是 `JsonifyObject<{}>`。 注意**这里没有**诊断信息。声明时没有,第一次应用时没有,坍缩时也没有。编译器只是安静而随意地返回了错误的类型。 ## 合理修复方案的坟墓 我不是第一个尝试解决这个问题的人。在我之前有两个社区 PR 已经尝试过——都带有回归测试,都修复了所报告的复现问题——但每个都恰好浅了一层,而且失误方式很有启发性。 第一个方案攻击了组合:如果中间件堆栈中的每个中间件都使用默认转换,则只应用一次 `Jsonify`,而不是每个中间件都应用一次。这样修复了报告的场景(全默认堆栈)。但一旦混入一个带有自定义转换的中间件,检查就会退回到旧路径,剩余的默认转换再次堆叠,坍缩卷土重来。 第二个方案保护了转换本身——老实说这也是我第一时间会想到的方案:“如果输入已经是纯 JSON,就不再重新应用 `Jsonify`。”它的作者将坍缩理解为实例化深度问题,这是一个非常合理的猜测。但这个保护有两个漏洞。首先,我们的 `Jsonify` 有意保留了 `unknown` 而不是将其拓宽,而 `unknown` 不能赋值给 `JsonValue`,因此任何包含 `unknown` 的类型都会漏过保护并像之前一样坍缩。其次,它只修补了**一个**组合点。`step.invoke` (https://www.inngest.com/docs/reference/typescript/v4/functions/step-invoke?ref=blog-adding-a-second-middleware-broke-our-typescript-types) 也将 `Jsonify` 与自身组合:调用一个处理程序返回 `step.run` 结果的函数时,即使没有中间件也会导致双重应用。修补了中间件堆栈,但 bug 仍然存在于这个基本操作中。 然后我检查了上游源头。`type-fest` 自身的兄弟过滤器 `FilterDefinedKeys` 和 `FilterOptionalKeys` 都基于完全相同的惯用做法,并且都将结果包裹在 `Exclude<..., undefined>` 中。而 `FilterJsonableKeys` 却没有。有人之前遇到过这类 bug,修复了能看到的两个实例,却漏掉了第三个。生活就是这样。 ## 在 `undefined` 进入的地方修复 到目前为止,每个修复都修补了腐败变得可见的位置,但 bug 本身存在于 `undefined` 进入的地方: ```ts type FilterJsonableKeys<T> = { [K in keyof T]: T[K] extends NotJsonable ? never : K }[keyof T]; // ^^^^^^^^ // 这里!读取可选属性时会包含 undefined ``` 将 `undefined` 排除在联合类型之外,它就永远不会进入对象的键集合。第一次应用会产生一个规范的类型,所以重新应用 `Jsonify` 就是空操作,并且所有组合点同时得到修复。同样的修复现在已提交给上游的 type-fest。 验证需要回答两个问题: - 新的 `Jsonify` 是否会改变从未损坏过的类型?我在结构上比较了旧版和新版(应用一次),遍及我能想到的所有形状——联合类型、元组、`Record`、`readonly`、深层嵌套的可选属性——结果在每个案例中都完全一致。 - 它真的修复了 bug 吗?我重新运行了旧代码中全面坍缩的双重应用测试集。全部通过。 最重要的是,vim 中不再有红色的波浪线了。 ## 你的类型测试可能会欺骗你 最后一个陷阱,它差点骗到我!这里的自然回归测试是: ```ts type Twice = Jsonify<Jsonify<Widget>>; type Same = IsEqual<Twice, Jsonify<Widget>>; // 我期望它能失败 ``` 我针对**损坏的**代码运行了这个,预期看到它失败。然而它通过了。 这是因为 `IsEqual` 比较的是两个仍然处于延迟状态的别名!TypeScript 可以在不完全求值的情况下关联它们,而损坏只有在求值被强制时才会显现。实际上会在应该失败时失败的断言是那个看起来平平无奇的断言: ```ts // 实际比较 type Result = Twice['media']; // 强制求值 ``` 属性访问会强制解析。如果你的类型级测试只断言组合别名之间的相等性,它们可能什么都没有断言。 (如果你在为他人构建库类型,**将这些类型视为 API** (https://www.inngest.com/blog/typescript-types-as-api?ref=blog-adding-a-second-middleware-broke-our-typescript-types) 是正确的思维模型——而这也正是这个模型可能悄悄辜负你的又一种方式。) ## 经验教训 1. 如果你使用 `{ ... }[keyof T]` 惯用做法来过滤键,可选属性会将 `undefined` 引入结果。使用 `Exclude` 将其排除,即使目前看起来并不必要。 2. 泛型约束在泛型编写处进行检查,而不是在之后用具体类型展开时重新检查。一个畸形的类型可能被静默地制造出来,并在任何东西强制它解析之前传播很长的距离。 3. TypeScript 有一个内部错误类型,它会静默地吞噬它所触及的一切。当它最终到达你面前时,可能已经被洗白成看起来合法的东西:`keyof` 出错表现为 `unknown`,`Pick` 应用在沾染的键上表现为 `{}`。这就是 `JsonifyObject<{}>` 中的 `{}`——并非诊断意义上的“空对象”,只是残骸**碰巧呈现的**形状。一个错误但安静的类型比一个大声失败的隐藏得更好。 4. 如果一个类型转换可以与自身组合,请测试组合结果:`f(f(x))` 应该等于 `f(x)`。 5. 通过属性访问而非仅靠别名上的 `IsEqual` 来断言类型测试;延迟类型的相等性远比看起来要弱。 关于这个修复,我最喜欢的部分是它多么简洁。一个 `Exclude`,就可让一整类静默类型损坏变得不可能:在中间件堆栈 (https://www.inngest.com/docs/features/middleware?ref=blog-adding-a-second-middleware-broke-our-typescript-types) 中、在 `step.invoke` 中、在尚未有人编写的组合中。`Jsonify` 回归为 TypeScript SDK (https://www.inngest.com/blog/typescript-sdk-v4.0?ref=blog-adding-a-second-middleware-broke-our-typescript-types) 中最无趣的一部分。而无趣就是胜利条件。一个无趣的类型是没有人需要去了解的类型:没有需要记住的漏洞,没有沾染的键集合,没有关于可以安全堆叠多少个中间件的传说。侦探服可以放回衣柜了。 ## 附言:有更好的方法 我提交了 `Exclude` 并继续前进。然后 som-sm 在评审中指出这个修复仍然浅了一层。它**没错**,但绝对可以更好!我的修复保留了旧形状:构建键的联合类型,用 `Exclude` 排除掉 `undefined`,然后传入 `Pick`。 **但是**,`undefined` 之所以在那里,仅仅是因为我通过值联合 `{ ... }[keyof T]` 读出键,而读取可选属性会拖带上 `undefined`。其他每一步都是为了清理这一步而存在的。跳过这一步,所有这些步骤也一并消失。 原地过滤键: ```ts type FilterJsonableKeys<T> = { [K in keyof T as T[K] extends NotJsonable ? never : K]: T[K]; }; ``` 使用 `as` 进行键重映射,在**它仍然是键的位置**测试每个键是否满足 `NotJsonable`,并通过映射为 `never` 来丢弃失败者。这里没有值联合步骤,因此不会有 `undefined` 泄漏,也不需要记住 `Exclude`,而 `FilterJsonableKeys` 和 `Pick` 都会自我删除。相同输出,经过相同测试集的验证。我的 `Exclude` 让 bug 变得不可能;而 `as` 子句让**本来包含 bug 的形态**变得不可能。无趣赢了两次。

相似文章

TypeScript 如何分配联合类型

Lobsters Hottest

一篇深度文章,解释 TypeScript 如何在重载函数、方法接收者和条件类型中分配联合类型,并包含示例和解决方法。

解析,而非验证——在并不鼓励你这样做的语言中

Hacker News Top

一篇探讨在TypeScript中应用“解析,而非验证”原则的博客文章,展示了如何使用品牌类型(branded types)在解析后保留类型信息,尽管TypeScript的结构类型系统使得这种做法不如在Elm或Haskell等语言中那样自然。

microsoft/TypeScript

GitHub Trending (daily)

TypeScript 是一种用于大规模 JavaScript 应用的语言,它增加了可选类型。该仓库托管了 TypeScript 编译器及相关工具。

TypeScript 7

Hacker News Top

TypeScript 7 是一个重大版本,它将编译器用 Go 语言重写,实现了 8-12 倍的构建速度提升,同时保持完全兼容性,现已在 npm 上可用。