逝去的 TypeScript bug 突然开始攻击我!!!

143 阅读3分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第13天,点击查看活动详情

问题来源

某天,小A正在练习类型体操,深入学习TypeScript类型操作相关知识,想要实现一个名为MyReadonly2的类型,该类型是内置工具类型Readonly的pro版本,MyReadonly2<T, K>带有两种类型的参数TK,能够实现可选的Readonly,如果未提供K,则应使所有属性都变为只读,就像普通的Readonly<T>一样.

例如

interface Todo {
  title: string
  description: string
  completed: boolean
}

const todo: MyReadonly2<Todo, 'title' | 'description'> = {
  title: "Hey",
  description: "foobar",
  completed: false,
}

todo.title = "Hello" // Error: cannot reassign a readonly property
todo.description = "barFoo" // Error: cannot reassign a readonly property
todo.completed = true // OK

小A一开始是这样实现的:

// solution 1
type Readonly<T, K extends keyof T = keyof T> = T & {
  readonly [P in K]: T[P]
}

好像没什么问题,去试试看!!!

CPT2206282256-747x392.gif

Opps! 出问题了,这是怎么回事呢?小A赶紧去翻了翻其他解法,然后他找到了这样一种解法, here

// solution 2
type Readonly<T, K extends keyof T = keyof T> = Omit<T, K> & {
  readonly [P in K]: T[P]
}

// 即:
type Readonly<T, K extends keyof T = keyof T> = Omit<T, K> & Readonly<T>

这两看上去也挺像啊,不都是两个对象类型交叉吗,为什么最初的解法就不行呢?

image.png

逝去的bug

实际上,solution 1 在TypeScript 4.4+版本及以下是可以正常工作的,而截至目前ts playground中的TypeScript版本最高已经到4.8-beta了solution 1 的这种反常行为实际上是TypeScript的一个bug,只不过说这个bug已经在这个 PR 中被修复了。

解析

solution 1 的问题总结一下就是,TypeScript 如何表示存在同名属性的两个对象类型的交叉类型,且其中一个对象的同名属性是只读的。

映射到具体的例子上就是这样的: {readonly a: string} & {a: string}应该等同于{readonly a: string}还是{a: string}

交叉类型在概念上意味着“与”,这意味着{readonly a: string} & {a: string}既是一个具有可读属性a的对象与一个具有可读和可写属性a的对象取 "交集" 的结果。这意味着最终的a属性是可读可写的,也就是说最终返回的对象应该是这样的:{a: string}.

从这个例子中可以得出,对于两个具有同名属性的对象类型形成的交叉类型,只有这两个对象中的同名属性都是只读的,最终对象中的同名属性才是只读的。

如果说只是其中一个对象类型对应的同名属性是只读的,最终对应属性的结果是可读可写的。 image.png 这时候再转头看看 solution 1, 应该能够明白为什么这种解法是错误的.

// solution 1
type Readonly<T, K extends keyof T = keyof T> = T & {
  readonly [P in K]: T[P]
}

Omit<T, K> & Readonly<T>之所以能够正常工作, 是因为Omit<T, K>已经确保了交叉类型的两个成员的同名属性不会包括K, 也就是说K不会出现在keyof Omit<T, K>中,只会出现在Readonly<T>中,且这时K是只读的,那么最终结果中的属性K也是只读的。

更多