Challenge

实现一个通用的MyReadonly2<T, K>,它带有两种类型的参数TKK指定的T的 属性集,应该设置为只读。如果未提供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

解答

这个挑战是Readonly<T>挑战的延续,除了需要添加一个新的类型参数K,以便我们可以 将指定的属性设为只读外,一切都基本相同。

我们从最简单的例子开始,即K是一个空集合,因此没有任何属性需要设置为只读。我们 只需要返回T就好了。

type MyReadonly2<T, K> = T;

现在我们需要处理这样一种情况:即在K中提供对应属性,我们利用&操作符使两种类型 产 生交集: 一个是之前提到的类型T,另一个是含有只读属性的类型。

type MyReadonly2<T, K> = T & { readonly [P in K]: T[P] };

看起来是一种解决方案,但是我们得到一个编译错 误:Type ‘P’ cannot be used to index type ‘T’。这是对的,因为我们没有对K设置 约束,它应该是 “T中的每一个键” :

type MyReadonly2<T, K extends keyof T> = T & { readonly [P in K]: T[P] };

正常工作啦? 🙅‍ 不!

我们还没有处理当K什么都没有设置的情况,该情况下我们的类型必须和通常 的Readonly<T>表现得一样。为了修复这个问题,我们将K的默认值设为”T的所有键 “。

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

你可能发现solution-1在 TypeScript 4.5 及以上的版本中不能正常工作,因为原本的行 为在 TypeScript 中是一个 bug(在microsoft/TypeScript#45122中 列出, 在microsoft/TypeScript#45263中 被修复,在 TypeScript 4.5 版本中正式发布)。从概念上来说,交叉类型意味着 “与”, 因此{readonly a: string} & {a: string}{a: string}应该是相等的,也就是说属 性a是可读且可写的。

在 TypeScript 4.5 之前, TypeScript 有着相反的不正确的行为,也就是说在交叉类型 中,一些成员的属性是只读的,但在另外成员中同名属性是可读可写的,最终对象的相应属 性却是只读的,这种行为是不正确的,但这已经被修复了。因此这也就解释了为什 么solution-1不能正常工作。想要解决这个问题,可以像下面这样写:

//Solution-2
type MyReadonly2<T, K extends keyof T = keyof T> = Omit<T, K> & {
  readonly [P in K]: T[P];
};
//i.e.
type MyReadonly2<T, K extends keyof T = keyof T> = Omit<T, K> & Readonly<T>;

因为K中的键都没有在keyof Omit<T, K>中出现过,因此solution-2能够向相应属性 添加readonly修饰符。

参考