面试官:请实现 TS 中的 Pick 和 Omit

发布于:2024-04-30 ⋅ 阅读:(35) ⋅ 点赞:(0)

遥远的面试

  • 面试官:“用过 TypeScript 吗?”
  • 我:“用过!”(我的内心:来吧,该背的知识点我都已经背过了,什么枚举和常量枚举的区别,interfacetype 区别,nevervoid 的区别,什么是逆变,什么是协变……)
  • 面试官:“那你写一个 PickOmit 吧”
  • 我:35_b5690dc57d27793663b5904e8ea2933a.jpg

PickOmit 作为 TypeScript 中的内置的工具类型,用倒是用过很多,但是实现嘛……确实需要学习下。

Pick

Pick 是 TypeScript 中的一个内置的工具类型,它可以实现从一个已有的类型中挑选出一部分属性,生成一个新的子类型。

举个例子:

// Person 类型,包含三个属性
type Person = {
    name: string;
    age: number;
    address: string;
};

// 使用 Pick 来从 Person 中挑选出 name 和 age 两个属性
type PartialPerson = Pick<Person, 'name' | 'age'>;

// 生成只包含 `name` 和 `age` 两个属性的新类型
// 等价于:
// type PartialPerson = {
//     name: string;
//     age: number;
// };

接下来看一下 Pick 的实现:

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

这段代码是 TypeScript 中 Pick 类型的定义,我们来逐一拆解一下这段代码中包含的语法知识点:

泛型

泛型可以让我们把类型当做一个变量,通过 <> 传入,这段代码传入 TK 两个类型变量,泛型可以让我们的类型定义更具有通用性。

假设我们有一个 identity 函数,这个函数会返回任何传入它的值。我们可以给类型一个具体的定义比如 numberany,如下:

function identity(arg: number): number { return arg; }

function identity(arg: any): any { return arg; }

但是前者会缩小函数的适用范围,后者又会丢失一些信息,此时我们就需要泛型出场了。

function identity<T>(arg: T): T { return arg; }

我们定义了一个类型变量 TT帮助我们捕获用户传入的类型(比如:number),之后我们就可以使用这个类型。之后我们再次使用了 T 当做返回值类型。现在我们可以知道参数类型与返回值类型是相同的了。

有了泛型,我们可以定义泛型函数,泛型接口,泛型类,我们可以在使用时,通过 <> 传入类型参数,也可以让 TS 去推测类型。

// 泛型函数
function identity<T>(arg: T): T { return arg; }
// 传入类型参数
let output = identity<string>("myString");
// 利用了类型推论,让编译器根据传入的参数自动地帮助我们确定 T 的类型
let output = identity("myString");
// 泛型接口
interface GenericIdentityFn<T> { (arg: T): T; }
let myIdentity: GenericIdentityFn<number> = identity;
// 泛型类
class GenericNumber<T> {
  zeroValue: T;
  add: (x: T, y: T) => T;
}
let myGenericNumber = new GenericNumber<number>();

泛型约束

我们把类型当做变量传入,却没有给它加任何限制,而有时候,我们只想对一组类型进行操作,在下面 loggingIdentity 的例子中,我们想访问 arglength 属性,但是编译器并不能证明每种类型都有length属性,所以就报错了。

function loggingIdentity<T>(arg: T): T {
  console.log(arg.length); // Error: T 没有 length 属性
  return arg;
}

我们需要限制传入的类型必须有 length 属性,此时可以使用 extends 来实现对泛型的约束。

interface Lengthwise {
  length: number;
}

function loggingIdentity<T extends Lengthwise>(arg: T): T {
  console.log(arg.length);  // 现在我们可以确定 T 有 length 属性了
  return arg;
}

这里的 extends 为泛型约束的关键字,表示 T 必须符合 Lengthwise 接口。

keyof

keyof 运算符接收一个对象类型,并产生其键的字符串或数字字面联合。

type Point = { x: number; y: number };
type P = keyof Point; // type P = "x" | "y"

type Arrayish = { [n: number]: unknown };
type A = keyof Arrayish; // number

type Mapish = { [k: string]: boolean };
type M = keyof Mapish; // string | number

在最后的示例中,Mstring | number,因为 JavaScript 对象键会被强制转为字符串,所以 obj[0] 等同于 obj["0"]

到此,我们可以理解 type Pick<T, K extends keyof T> 这行代码:

我们定义一个类型 Pick 并传入了,T,K 两个泛型,其中 K 被约束为 T 的键值的联合类型。比如,T{a:xx,b:xx},那么 K 只能为 aba|b

索引类型和索引类型查询

类似于在 JavaScript 中,通过下标或字符串来访问对象的元素或属性,在 TypeScript 中,我们也可以通过下标或属性名,来访问对应元素或属性的类型。

type Arr = [number, string]
type Obj = {
  name: string
  age: number
}
type A = Arr[0] // number
type O = Obj['name'] // string

[P in K] 中,我们通过 in 来遍历联合类型,在此处,P 是一个临时变量,代表 K 中的每一个成员。K 是一个联合类型,表示一个类型的集合。所以 P in K 就表示遍历 K 中的每一个类型。

T[P] 这是一个索引访问类型,表示访问 TP 属性的类型。我们知道,在有了 K extends keyof T 的限制后,此处的 P 必是 T 的属性名,所以 T[P] 就表示 TP 属性的类型。

总结

所以,整个 Pick 类型的含义是:指定 T 中的一些属性名集合 K,取出 T 中所有 K 包含的属性名和它在 T 中对应的类型,来组成一个新的类型。

Omit

Omit 也是 TypeScript 内置的工具类型,和 Pick 相反,Omit 用于从一个已有的类型中删除某些属性,来生成一个新的属性。定义:

type MyOmit<T, K extends keyof T> = ...

实现 Omit 最简单的思路,我们从 Pick 出发,既然 Omit 是删除指定属性,那我们也可以反向 Pick 不被删除的属性。这样问题就变成了,如何获取 T 中不在 K 中的属性集合,刚好 TypeScript 内置的 Exclude 可以帮我们实现。

Exclude

Exclude<T, U> 是 TypeScript 中的一个内置工具类型,用于从一个联合类型 T 中把符合 U 的成员删除,生成一个新的类型。

举个例子:

type Result = Exclude<'a' | 'b' | 'c', 'a'>;  // 'b' | 'c'
type Result = Exclude<'a' | 'b' | 'c', 'a' | 'b'>;  // 'c'

我们来看下 Exclude 的实现:

type MyExclude<T, U> =  T extends U ? never : T;

Exclude 的实现很简单,但是这里面涉及一个的知识点:条件类型。

条件类型

在 TypeScript 中,条件类型是一种动态决定类型的方式。它的基本形式是 T extends U ? X : Y,表示如果类型 T 可以赋值给类型 U,那么结果类型就是 X,否则结果类型就是 Y

例如,我们可以定义一个 IfString 类型,如果给定的类型是 string,那么结果类型就是 'yes',否则结果类型就是 'no'

type IfString<T> = T extends string ? 'yes' : 'no';

然后我们可以像下面这样使用这个条件类型:

type T1 = IfString<string>;  // 'yes'
type T2 = IfString<number>;  // 'no'

在这个例子中,T1'yes',因为 string 可以赋值给 stringT2'no',因为 number 不能赋值给 string

分布式条件类型

分布式条件类型是条件类型的一个特性:当条件类型遇到联合类型时,会被应用到联合类型的每一个成员上,这就是所谓的“分布式”行为。

例如,假设我们有一个条件类型 T extends U ? X : Y,如果 T 是一个联合类型 A | B | C,那么这个条件类型就会被展开为

(A extends U ? X : Y) | (B extends U ? X : Y) | (C extends U ? X : Y)

实现1: Pick & Exclude

现在我们可以轻松实现 Omit

type MyOmit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>

实现2: 不使用 Pick

当然,我们也可以不用 Pick,既然我们已经可以了解了 Pick 的实现原理。

type MyOmit<T, K extends keyof T> = {
  [P in Exclude<keyof T, K>]: T[P];
}

实现3: 抛弃 Pick 和 Exclude

理所当然的,我们学会了 PickExclude 的实现方式,理论上我们就可以不使用他们来实现了。

但是如何把 Exclude 的实现加入到实现2中,还是有点难度,我一开始尝试这种实现方式:

type MyOmit<T, K extends keyof T> = {
  [P in keyof T]: P extends K ? never : T[P];
}

但是这样是不正确的,事实上这种实现方式并没有删除 K 中指定的属性,而是把类型改为了 never

显然,我们需要在属性键类型上做一些操作,而不是属性值类型。我们尝试这样实现,但是会遇到语法错误,TypeScript 不允许在映射类型的键迭代部分直接使用条件类型。

type MyOmit<T, K extends keyof T> = {
  [P in keyof T extends K ? never : P]: T[P];
};

我们来直接看一下正确的实现方式:

type MyOmit<T, K extends keyof T> = {
  [P in keyof T as P extends K ? never : P]: T[P];
};

这里又涉及到一个新的语法,as 在映射类型中用于重映射键名。

as

在 TypeScript 中,as 一般用于类型断言,比如:

toast((error as { msg: string })?.msg);

在 TypeScript 4.1 及更高版本中,as 关键字还可用于映射类型,以重新映射键名,这样就可以在创建新类型时更改键名。

比如:

type PrefixWithUnderscore<T> = {
  [K in keyof T as `_${string & K}`]: T[K];
};

type Original = {
  id: number;
  name: string;
};

type Prefixed = PrefixWithUnderscore<Original>;
// 等同于 { _id: number; _name: string; }

回到我们的 Omit

type MyOmit<T, K extends keyof T> = {
  [P in keyof T as P extends K ? never : P]: T[P];
};

我们利用了 as 来对每一个属性键 P 来进行条件判断:如果属性键 P 属于类型 K,则结果类型为 never,这样该属性就不会被包含在最终类型中,而如果 P 不属于 K,则保持属性键 P 不变。

这样我们就完成了不依赖任何内置类型工具实现 Omit

参考


网站公告

今日签到

点亮在社区的每一天
去签到