看懂复杂的 TypeScript 泛型运算

看懂复杂的 TypeScript 泛型运算已关闭评论

在阅读本文之前,假定你已阅读过 TypeScript 官方文档。

对于从 JavaScript 转来的 TypeScript 的初学者来说,一开始无脑用一个新的 interface规定一切的方式确实很过瘾。但是,当不得不尝试提高函数或组件的通用性时,使用泛型成了必须的选择。

当尝试阅读一些用 TypeScript 开发的库的源码时,可能会被各种尖括号包裹的复杂泛型运算搞的晕头转向。本文的目的在于对泛型中的常见关键词和用法进行介绍,并且尝试用一定数量的例子来方便理解泛型。

TypeScript中, typeinterface关键字中在多数情况下功能是相同的,因此在本文的例子中不会刻意进行区分。首先定义下面一个类型,便于后面的使用。

type User = {  name: string;  age: number;};

关键字

keyof 关键字

keyof的功能较为简单,对于一个 object 类型,得到这个类型的所有属性名构成的联合类型。

type TA = keyof User;// 'name' | 'age'

在这个例子中,我们得到一个新的类型 TA,这个类型的实例必须为 'name''age'这两个字符串之一,这样的单一字符串也是一种类型,属于字面量类型。

typeof 关键字

typeof是针对某一个类型的实例来讲的,我们将得到这个实例的类型。

const fn = () => ({ name: "blasius", age: 18 });type TB = typeof fn;// () => {name: string, age: number}

这里的类型 TB,是一个新的类型,写作 ()=>{name:string,age:number},这是一个函数类型。这个类型的函数的返回值是一个新的类型,写作 {name:string,age:number},然而,这个类型暂时还没有特定的名称。假如我们想把这个返回值的类型提取出来,可以使用 ReturnType这个工具类型,本文后面会具体介绍。

extends 关键字

extends关键字在类型运算中的作用,不是继承或者扩展,而是判断一个类型是否可以被赋值给另一个类型。如上面的类型 TB,是一个函数,因此这个类型是可以赋值给类型 Function

extends有时被用来进行类型约束。考虑下面的例子:

function logLength<T>(arg: T) {  console.log(arg.length);  // Property 'length' does not exist on type 'T'.}

此时我们无法保证类型 T一定包含 length这个属性,因此会出现错误。考虑进行如下修改:

// 定义一个类型ILengthyinterface ILengthy {  length: number;}function logLength2<T extends ILengthy>(arg: T) {  console.log(arg.length);}

对于函数 logLength2来说,我们规定了类型 T必须是 ILengthy可赋值的类型,也就是说, T必须包含类型为 number的属性 length,这样一来,我们成功对函数的参数进行了约束。

extends的另一种用法,是在类型运算中进行条件运算,具体用法将会在后面的工具类型中进行介绍。

infer 关键字

infer一般用于类型提取,其作用类似于在类型运算的过程中声明了一个变量。考虑下面的例子:

type UserPromise = Promise<User>;

这个类型表示一个返回值类型为 UserPromise类型。我们想把 User这个类型从这个已知的函数中提取出来,应当使用 infer关键字:

type UnPromisify<T> = T extends Promise<infer V> ? V : never;type InferedUser = UnPromisify<UserPromise>;// { name: number; age: string; }

考虑这个例子中的 UnPromisify类型,这个类型接受一个泛型 T。接下来通过 extends关键字进行判断,如果 T的类型形如 Promise<V>,那么就把这个 V提取出来。为了更好的理解 infer的作用,在这个例子中,可以认为 infer声明了一个变量 V。这个例子,我们结合 extednsinfer实现了类型提取。

工具类型

所谓工具类型,形如 typeToolType<T,....>=R。为了便于理解我们可以将其看做是用 type关键字定义的一个封装好的针对类型的“函数”。传给工具类型的,被包裹在尖括号之内的泛型 T,就是函数的参数。等号右边的,就是这个“函数”的返回值。

有了所谓”函数“,也必须有”变量“。对于初学者来说,对于泛型感到不理解的主要困境在于:没有区分什么时候是类型的”函数“,什么时候是类型的”变量“。

上面提到的 UnPromisify<T>,就是这样一个类型的”函数“,因为尖括号中的 T是不确定的,因此称为泛型。相对的,上面提到的 UnPromisify<UserPromise>,则是这个“函数”的执行结果,可以理解为类型的”变量“,因为尖括号中的 UserPromise是一个确定的类型, {name:number;age:string;}就是这个具体的结果的值。

下面介绍几个常用的工具类型,这几个“函数”已经作为标准存在于 TypeScript中,分析这几个“函数”的具体实现,有利于我们更好地理解泛型。

Partial<T>、Required<T>、Readonly<T>、Mutable<T>

Partial<T>这个类型“函数”的作用,在于给定一个输入的 object 类型 T,返回一个新的 object 类型,这个 object 类型的每一个属性都是可选的。

我们可以用基本的关键字来用自己的方式实现这个工具类型:

type MyPartial<T> = {  [K in keyof T]?: T[K];};type PartialUser = MyPartial<User>;// {name?: string, age?: number}type TUserKeys = keyof User;// 'name' | 'age'type TName = User["name"];// stringtype TAge = User["age"];// numbertype TUserValue = User[TUserKeys];// string | number

上面的例子中, MyPartial<T>是工具类型本身, PartialUserMyPartial<T>传入了”参数” User经过运算后的结果,因此是工具类型使用的实例。下面我们来逐步理解这个例子:

  1. keyof T代表类型 T的所有键构成的联合类型,等同于 TUserKeys

  2. Kinkeyof T代表 K必须是这个联合类型中的一个

  3. 有了具体的键,参考 TName和 TAge的结果,就可以用 T[K]取出这个键对应的值的类型

  4. 至于中括号,这是 TypeScript中的索引签名的类型 综上, MyPartial<T>这个”函数”的”返回“值是一个新的 object 类型,这个类型的键和键的类型都和”输入参数“ T相同且一一对应,只不过每个键的后面都多了一个问号 ?用来表示这个键可选罢了。

如果能理解 Partial<T>的实现,那么 Required<T>Readonly<T>Mutable<T>的实现都是类似的。都是只不过是把 ?换成了 readonly或者 -用来表示不同的含义罢了。下面是这些工具类型的具体实现:

type MyRequired<T> = {  [K in keyof T]-?: T[K];};type MyReadonly<T> = {  readonly [K in keyof T]: T[K];};type MyMutable<T> = {  -readonly [K in keyof T]: T[K];};

Requiered<T>表示根据 T得到新的类型,这个类型的每个键值都为必需。Readonly<T>表示由 T得到新的类型,这个类型的每个键的值都为只读的。Mutable<T>表示同样由 T得到新的类型,这个类型的每个键的值为可写的。

Record<K, T>、Pick<T, K>

工具类型 Record<K,T>的实现:

type MyRecord<K extends keyof any, T> = {  [P in K]: T;};type TKeyofAny = keyof any;// string | number | symboltype TKeys = "a" | "b" | 0;type TKeysUser = MyRecord<TKeys, User>;// {a: User, b: User, 0: User}

Record<K,T>接受两个类型作为”参数“,其中第一个参数 K是一个任意字符串、数字或 Symbol 的联合类型,第二个“参数” T可以为任意类型。最终得到一个由 K中每个值作为键,值类型为 T的新的 object 类型。

类似的, Pick<T,K>的实现:

type MyPick<T, K extends keyof T> = {  [P in K]: T[P];};type TNameKey = "name";type TUserName = MyPick<User, TNameKey>;// {name: string}

Pick的功能很简单,从给定的类型 T中 pick 出特定的键和键类型,构成新的类型,另一个”参数“ K类型必须是 keyof T中的若干项构成的联合类型。

ExcludePick<T, U>、Extract<T, U>、NonNullable<T>

这三个工具类型的实现是类似的,都使用了 extends的基本用法来对联合类型进行条件性的选取, 已 Exclude<T,U>为例,若 T能够赋值给 U,则返回 never,否则返回 T本身,因此最终得到联合类型中存在于 T中但不存在于 U中的项:

type MyExclude<T, U> = T extends U ? never : T;type MyExtract<T, U> = T extends U ? T : never;type MyNonNullable<T> = T extends null | undefined ? never : T;

Exclude<T,U>Extract<T,U>通常是针对联合类型来使用的,两者的逻辑恰好相反。例如:

type TC = "a" | "b" | "c";type TD = "a" | "c" | "e";type TE = MyExclude<TC, TD>;// 'b'type TF = MyExtract<TC, TD>;// 'a' | 'c'

Omit<T, K>

这个类型“函数”接受两个“参数” TK,功能和 Pick<T,K>恰好相反,即从给定的类型 T中排除(exclude)掉特定的键和键类型,得到新的类型。因此,可以用 Pick配合 Exclude来实现。

type MyOmit<T, K> = Pick<T, Exclude<keyof T, K>>;type OmitUser = MyOmit<User, "age">;// { name: string }

思考 OmitUser的运算过程:
1、得到 keyofUser'name'|'age'
2、从 'name'|'age'中排除掉 'age',得到剩下的 'name'
3、 Pick<User,'name'>,得到剩下的 name,成为一个新的类型
芜湖,一切都很顺理成章。

当不希望使用已有的 Pick<T,K>工具类型时, Omit<T,K>还可以有另一种实现方式,观察其结构,可以发现 Pick<T,K>的影子。

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

构造函数类型和 InstanceType<T>

形如 new(args:any)=>any类型的函数,被称为构造函数类型。

下面的工具类型 InstanceType,用于取得构造函数的返回的实例的类型。

type MyInstanceType<T extends new (...args: any) => any> = T extends new (  ...args: any) => infer R  ? R  : any;

乍一看这个表达式十分复杂,但是主体结构仅仅是一个前面见过的 extends表达式而已:图中红色放方框中代表构造函数类型。绿色方框中用 infer关键字声明了一个新的“类型变量” R,若 T为构造函数类型,则可以得到该函数的返回实例的类型。

ReturnType<T>、Parameters<T>

ReturnType工具类型用于提取泛型 T的返回值。Parameters工具类型用于提取泛型 T的参数。

type MyReturnType<T extends (...args: any) => any> = T extends (  ...args: any) => infer R  ? R  : never;type MyParameters<T extends (...args: any) => any> = T extends (  ...args: infer P) => any  ? P  : never;

为了理解这两个工具类型的实现,只需同样要把握住 infer的位置和匿名的函数类型 (...args:any)=>any这两个要点即可。

总结

从上面的例子可以看出,所谓泛型,完全可以理解成一个类型的”函数“,把握住尖括号中的输入参数,注意观察等号右边的”函数返回值“。值得注意的是,尖括号中的内容,如果是形如 TK这样的,那么就是“函数”本身,如果尖括号内是一个确定的类型,那么就成了“函数”的执行结果。

一些常用的用 TypeScript写成的包如 Redux 的源码中,充斥着众多的泛型定义。对于用 JavaScript写成的包如 React,在使用时必须同时安装的包 @types/react,其主要内容也是大量的类型和泛型定义,了解泛型不但有助于理解这些包的用法,这些包的源码结构也可以一目了然。这些,就是理解泛型运算的意义之所在。


关注我们

我们将为你带来最前沿的前端资讯。

来源: 印记中文