用 Object.assign 移除类型断言

Lobsters Hottest 工具

摘要

一篇博客文章,展示了如何通过使用真实的 Response 对象配合 Object.assign 来消除 TypeScript 的 `as unknown as` 类型断言,并解释了与 `unknown` 的交叉类型如何使类型检查更加安全。

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

缓存时间: 2026/10/19 16:02

# 使用 Object.assign 移除类型断言 来源:https://ataias.com.br/2026/10/01/removing-a-type-assertion-with-object.assign/ 我非常厌恶 TypeScript 中的类型断言。每一个 `as` 都是我在告诉编译器"相信我",而每一个 `as unknown as` 则是我让它彻底停止思考。所以,当我在编写一个适配器函数时发现了一处类型断言,我就想把它除掉。在这个过程中,我学到了一个关于 `unknown` 和 `Object.assign` 如何协同工作的有趣知识。 ## 适配器 大致的场景是这样的:某个库暴露了一个函数,它接受一个客户端(client),而这个客户端必须有一个 `request` 函数,该函数返回一个带有类型化 `json` 方法的 `Response`。在我这边,已经有一个函数可以直接发起请求并返回解析后的数据。我只需要把其中一方适配到另一方即可。下面是它的最小版本: ```ts type TypedResponse = Omit<Response, 'json'> & {json: () => Promise<unknown>} type Client = { request: (query: string) => Promise<TypedResponse<{data?: unknown}>> } type FetchData = (query: string) => Promise<unknown> ``` 这个库只会对响应调用 `json()`,所以我最初的版本只是返回一个带有该方法的对象: ```ts export function toClient(fetchData: FetchData): Client { return { request: async (query) => { const data = await fetchData(query) return {json: async () => ({data})} }, } } ``` TypeScript 正确地拒绝了它: ``` Type '{ json: () => Promise<{ data: unknown; }>; }' is not assignable to type 'TypedResponse<{ data?: unknown; }>'. Type '{ json: () => Promise<{ data: unknown; }>; }' is missing the following properties from type 'Omit<Response, "json">': body, bodyUsed, arrayBuffer, blob, and 11 more. ``` 在我之前存在的代码通过用 `as unknown as Client` 强制转换整个客户端来解决了这个问题。它能工作,但也意味着如果库修改了 `Client` 类型,编译器不会说一句话。 ## 修复方法 我想要的是一个真正的 `Response`,只不过带有不同的 `json` 方法。而这正是 `Object.assign` 所提供的: ```ts export function toClient(fetchData: FetchData): Client { return { request: async (query) => { const data = await fetchData(query) return Object.assign(new Response(), {json: async () => ({data})}) }, } } ``` 没有断言,而且类型检查通过。为了理解其中的原因,我写了一个更小的例子。 ## 为什么可行 这里是简化后的版本([playground 链接](https://www.typescriptlang.org/play/?#code/C4TwDgpgBAggjFAvFA3gQwFxQK4DsDWuA9gO64A0UARlrtgLZUQBOAvgFCiSwBMSqmKAAoAlEgB8UOoxYd2AYyK4AzsCjA0zAOYRgWeP3RZREqAFZKNKDw6KVa5UWzN5EfX2RHhYxJLO2lVSh6EAA1NAAbbGhkAHkqACsIeWAAOjRlZQBLLVwhDW1dSkdnVxEFQKIIiFSIoi0hEPCoiBEgA)): ```ts type A1 = {a: unknown, b: number} type A2 = {a: () => number} const target: A1 = {a: () => 5, b: 2} const source: A2 = {a: () => 5} const myValue = Object.assign(target, source) console.log(myValue) ``` 如果你把鼠标悬停在 `myValue` 上,会看到它的类型是 `A1 & A2`,调用 `myValue.a()` 可以正常编译。然而,`target.a()` 则不行: ``` error TS18046: 'target.a' is of type 'unknown'. ``` 这乍一看有点令人惊讶,因为 `myValue` 和 `target` 是完全相同的对象。`Object.assign` 会修改目标对象并返回它,所以在运行时 `myValue === target` 为 `true`。TypeScript 只是对其中一个名字比另一个了解更多。 答案就在 `lib.es2015.core.d.ts` 中 `Object.assign` 的类型声明里: ```ts assign(target: T, source: U): T & U; ``` 返回类型是目标类型和源类型的交叉类型。当你将两个对象类型进行交叉时,同名的属性也会被交叉,所以 `a` 就变成了 `unknown & (() => number)`。由于 `unknown` 是顶层类型(任何类型都可以赋值给它),将任何东西与 `unknown` 交叉得到的还是那个东西本身,就像 `x && true` 就是 `x` 一样。所以 `a` 就简化为 `(() => number)`。 在这种情况下,类型与运行时的行为是一致的。根据 [MDN 文档](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/assign),`Object.assign` 会把源对象上所有可枚举的自有属性复制到目标对象上,目标对象中与源对象同键的属性会被覆盖。所以调用之后 `a` 确实就是 `source` 中的那个函数。 回到适配器:`Object.assign(new Response(), {json})` 的类型是 `Response & {json: () => Promise<{data: unknown}>}`。`Omit` 所要求的一切都来自 `Response` 那一侧,而 `json` 来自我传入的对象,所以它可以赋值给 `TypedResponse<{data?: unknown}>`。调用方仍然会得到一个类型化的 `json()`,它解析为 `{data?: unknown}`,因为 `TypedResponse` 中的 `Omit` 把原来的 `json(): Promise<string>` 声明丢弃了。而在运行时它确实是一个 `Response`,所以 `ok`、`status` 和 `headers` 都存在。 ## 为什么不使用展开运算符? "复制这个对象但替换其中一个字段"的常见解决方案通常是展开语法。使用标准的 `lib.dom.d.ts` 类型,TypeScript 在这里甚至也能接受: ```ts const spread: TypedResponse<{data: number}> = { ...new Response(), json: async () => ({data: 1}), } ``` 但这在运行时是坏掉的。`Response` 将其属性保留为原型上的 getter,而不是自有属性,而展开只会复制可枚举的自有属性。分别在 Node 和 Bun 中运行: ```ts console.log(spread.ok, spread.status, spread instanceof Response) // undefined undefined false const assigned = Object.assign(new Response(), {json: async () => ({data: 1})}) console.log(assigned.ok, assigned.status, assigned instanceof Response) // true 200 true ``` TypeScript 无法发现这个问题,因为 `Response` 在 `lib.dom.d.ts` 中被声明为一个接口,所以从类型系统的角度来看,展开它会复制所有东西。有意思的是,Bun 自己的类型(`@types/bun`)拒绝了展开的版本,抱怨缺少 `clone`,但这更多是碰巧,而不是对展开在运行时行为的真实建模。`Object.assign` 保留了原始对象,包括原型在内,所以它是这里的正确工具。 ## 它会在哪里失效 `T & U` 这个签名是一个不错的近似,但它并不是 `Object.assign` 真正的行为。源对象是*覆盖*目标对象,而不是与它*合并*。当类型是兼容的,比如 `unknown` 和一个函数,你不会注意到区别。当它们不兼容时,事情就变得奇怪了: ```ts const x = Object.assign({a: 1}, {a: "hello"}) const bad: never = x.a // 编译通过 console.log(typeof x.a) // "string" ``` 这里 `a` 变成了 `number & string`,也就是 `never`。TypeScript 很乐意让我把它赋值给一个 `never` 变量,而在运行时它只是字符串 `"hello"`。 ## 总结 总结一下: - `Object.assign(target, source)` 的类型是 `T & U`。 - 与 `unknown` 交叉是无操作,所以一个 `unknown` 字段可以被源类型"升级"。 - 这使得 `Object.assign(new Response(), {json})` 成为构建一个带有自定义 `json` 的真正 `Response` 的方式,无需任何类型断言。 - 展开语法在同一场景下也能通过类型检查,但会丢失原型上的所有内容。 - 当属性类型冲突时,交叉类型可能坍缩为 `never`,而运行时的值却完全正常。 我不是 TypeScript 编译器专家,所以如果我的推理中有错误,请告诉我。你通常是如何消除代码中的类型断言的?

相似文章

TypeScript 如何分配联合类型

Lobsters Hottest

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

将引用稳定性变为一种类型

Hacker News Top

一篇关于使用幻影品牌将引用稳定性编码为 TypeScript 类型的博客文章,从而使 React props 能够强制要求数组、回调函数和对象在多次渲染中保持稳定。

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

Hacker News Top

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