面试官:请实现 TS 中的 Pick 和 Omit面试官:“用过 TypeScript 吗?” 我:“用过!” 面试官:
遥远的面试
- 面试官:“用过 TypeScript 吗?”
- 我:“用过!”(我的内心:来吧,该背的知识点我都已经背过了,什么枚举和常量枚举的区别,
interface和type区别,never和void的区别,什么是逆变,什么是协变……) - 面试官:“那你写一个
Pick和Omit吧” - 我:

Pick 和 Omit 作为 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 类型的定义,我们来逐一拆解一下这段代码中包含的语法知识点:
泛型
泛型可以让我们把类型当做一个变量,通过 <> 传入,这段代码传入 T 和 K 两个类型变量,泛型可以让我们的类型定义更具有通用性。
假设我们有一个 identity 函数,这个函数会返回任何传入它的值。我们可以给类型一个具体的定义比如 number 或 any,如下:
function identity(arg: number): number { return arg; }
function identity(arg: any): any { return arg; }
但是前者会缩小函数的适用范围,后者又会丢失一些信息,此时我们就需要泛型出场了。
function identity<T>(arg: T): T { return arg; }
我们定义了一个类型变量 T,T帮助我们捕获用户传入的类型(比如: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 的例子中,我们想访问 arg 的 length 属性,但是编译器并不能证明每种类型都有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
在最后的示例中,M 是 string | number,因为 JavaScript 对象键会被强制转为字符串,所以 obj[0] 等同于 obj["0"]。
到此,我们可以理解 type Pick<T, K extends keyof T> 这行代码:
我们定义一个类型 Pick 并传入了,T,K 两个泛型,其中 K 被约束为 T 的键值的联合类型。比如,T为 {a:xx,b:xx},那么 K 只能为 a 或 b 或 a|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] 这是一个索引访问类型,表示访问 T 中 P 属性的类型。我们知道,在有了 K extends keyof T 的限制后,此处的 P 必是 T 的属性名,所以 T[P] 就表示 T 中 P 属性的类型。
总结
所以,整个 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 可以赋值给 string;T2 是 '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
理所当然的,我们学会了 Pick 和 Exclude 的实现方式,理论上我们就可以不使用他们来实现了。
但是如何把 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。
参考
转载自:https://juejin.cn/post/7363209007828746255