面试官:请实现 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