14 讲我们学习了常见 TypeScript 官方内置的工具类型(官方轮子),它们的本质就是自定义的复杂类型构造器(确切地讲是泛型)。这一讲我们就来学习一下如何自己造轮子,并剖析一些常见第三方轮子的实现。

类型物料

在正式造轮子之前,我们先来熟悉一下即将用到的物料,这可能涉及前面每一讲中的知识点和一些新语法。如果你连泛型特性都记不清的话,那么请至少从 10 讲开始温习一遍。

言归正传,接下来我们一起看看重度依赖的一些类型物料。

泛型

首先是泛型(回顾 10 讲),笔者认为工具类型的本质就是构造复杂类型的泛型。如果一个工具类型不能接受类型入参,那么它和普通的类型别名又有什么区别?因此,使用泛型进行变量抽离、逻辑封装其实就是在造类型的轮子,下面举一个具体的示例:

type isXX = 1 extends number ? true : false;

type isYY = ‘string’ extends string ? true : false;

在示例中的第 1~2 行,我们重复使用了 extends 关键字和三元运算符实现根据类型 1 和 number、’string’ 和 string 的子类型关系分别返回布尔字面量 true 或者 false,并给类型起了别名 isXX、isYY,这明显是一种效率低下的做法,因为我们不能把其中的逻辑复用在对其他类型子类型关系的判断上。这时,我们就需要把确切的类型抽离为入参,然后封装成一个可复用的泛型。

下面一起看一个具体的示例:

type isSubTying = Child extends Par ? true : false;

type isXX2 = isSubTyping<1, number>; // true

type isYY2 = isSubTyping<’string’, string>; // true

type isZZ2 = isSubTyping; // true

示例中的第 1 行封装出来的工具泛型其实是我们在 12 讲中使用到的工具类型 isSubTyping,如果类型入参 Child 是 Par 的子类型,则返回布尔字面量类型 true,否则返回 false。这样,我们就可以使用 isSubTyping 判断其他任意两个类型之间的子类型关系了。

比如示例中的第 2~4 行,因为 1 和 number、’string’ 和 string、true 和 boolean 都是子类型和父类型的关系,所以返回的都是布尔字面量类型 true。

条件类型

如我们在泛型中提到,TypeScript 支持使用三元运算的条件类型,它可以根据 ?前面的条件判断返回不同的类型。同时,三元运算还支持嵌套。

在三元运算的条件判断逻辑中,它主要使用 extends 关键字判断两个类型的子类型关系,如下代码所示:

type isSubTyping = Child extends Par ? true : false;

type isAssertable = T extends S ? true : S extends T ? true : false;

type isNumAssertable = isAssertable<1, number>; // true

type isStrAssertable = isAssertable; // true

type isNotAssertable = isAssertable<1, boolean>; // false

以上示例,除了在第 1 行定义了 isSubTyping 之外,我们还在第 2 行定义了使用嵌套的三元运算,用来判断类型入参 T 是否可以被断言成类型 S(T as S)的泛型 isAssertable,并使用 extends 关键字判断入参 T 是否是 S 的子类型或 S 是 T 的子类型,从而判断它们之间的可断言关系。

在示例中的第 3~5 行,因为 1 是 number 类型的子类型,满足条件分支 T extends S,所以返回了 true;因为字符串字面量 ‘string’ 是 string 类型的子类型,满足条件分支 S extends T,所以返回了 true;因为 1 既不是 boolean 的子类型,也不是父类型,不满足任何条件分支,所以返回了 false。

下面我们一起来看看条件类型的另外一个特性:分配条件类型。

分配条件类型(Distributive Conditional Types)

如我们在 10 讲中提到,在条件类型中,如果入参是联合类型,则会被拆解为一个个独立的(原子)类型(成员),然后再进行类型运算。

下面我们看一个具体的示例:

type BooleanOrString = string | boolean;

type StringOrNumberArray = E extends string | number ? E[] : E;

type WhatIsThis = StringOrNumberArray; // boolean | string[]

type BooleanOrStringGot = BooleanOrString extends string | number ? BooleanOrString[] : BooleanOrString; // string | boolean

在示例中的第 3 行, string 和 boolean 组成的联合类型 BooleanOrString 作为泛型 StringOrNumberArray 入参的时候,则会被拆解成 string 和 boolean 这两个独立的类型,再通过 extends 关键字判断是否是 string | number 类型的子类型。

因为 string 是子集,而 boolean 不是,所以最终我们得到的 WhatIsThis 的类型是 boolean | string[]****但是,在非泛型条件类型中(示例中的第 4 行),因为 BooleanOrString 被当成了一个整体对待,所以 BooleanOrStringGot 的类型是 string | boolean。

同样,通过某些手段强制类型入参被当成一个整体,也可以解除类型分配,如下示例:

type StringOrNumberArray = [E] extends [string | number] ? E[] : E;

type WhatIsThis = StringOrNumberArray; // string | boolean

在示例中的第 1 行,我们使用 [] 将入参 E 包起来,即便入参是联合类型 string | boolean,也会被当成一个整体对待,所以第 2 行返回的是 string | boolean。

注意:包含条件类型的泛型接收 never 作为泛型入参时,存在一定“陷阱”,如下示例:

type GetSNums = never extends number ? number[] : never extends string ? string[] : never; // number[];

type GetNever = StringOrNumberArray; // never

在上述示例中的第 1 行,因为 never 是所有类型的子类型,自然也是 number 的子类型,所以返回的是 number 类型的数组在第 2 行传入 never 作为入参来实例化前面定义的泛型 StringOrNumberArray 时,返回的类型却是 never,而不是 number[]

你要知道,泛型 StringOrNumberArray 的实现与示例第 1 行“=”右侧的逻辑并没有任何区别(除 never 被抽离成入参之外)。这是因为 never 是不能分配的底层类型,**如果作为入参以原子形式出现在条件判断 extends 关键字左侧,则实例化得到的类型也是 never**。

下面看一个具体的示例:

type UsefulNeverX = T extends {} ? T[] : [];

type UselessNeverX = S extends {} ? S[] : [];

type UselessNeverY = S extends {} ? T[] : [];

type UselessNeverZ = [T] extends [{}] ? T[] : [];

type ThisIsNeverX = UsefulNeverX; // never

type ThisIsNotNeverX = UselessNeverX; // string[]

type ThisIsNotNeverY = UselessNeverY; // never[]

type ThisIsNotNeverZ = UselessNeverZ; // never[]

在示例中的第 1 行,因为我们定义的泛型 UsefulNeverX 的入参 T 被三元运算中的 extends 使用,所以第 5 行返回的类型是 never。而第 2 行、第 3 行定义的泛型入参 T 都没有被三元运算中的 extends 使用,所以第 6~7 行所返回的类型分别是 string[] 和 never[]。在第 4 行,因为入参 T 是以 T[] 而不是以原子形式被 extends 使用,所以第 8 行返回的类型也是 **never[]**。

条件类型中的类型推断 infer

另外,我们可以在条件类型中使用类型推断操作符 infer 来获取类型入参的组成部分,比如说获取数组类型入参里元素的类型

下面我们来看一个具体的示例:

{

type ElementTypeOfArray = T extends (infer E)[] ? E : never;

type isNumber = ElementTypeOfArray; // number

type isNever = ElementTypeOfArray; // never

}

在示例中的第 1 行,我们定义了接收入参 T 的泛型 ElementTypeOfArray,并在三元运算的条件判断中,通过 (infer E)[] 定义了一个有对元素类型推断参数 E 的数组。当入参 T 满足是 (infer E)[] 数组类型的子类型的条件,则返回参数 E,即数组元素类型,所以在第 3 行传入 number[] 入参时返回的是 number 类型,而传入 number 时返回的则是 never。

我们还可以通过 infer 创建任意个类型推断参数,以此获取任意的成员类型,如下示例:

{

type ElementTypeOfObj = T extends { name: infer E; id: infer I } ? [E, I] : never;

type isArray = ElementTypeOfObj<{ name: ‘name’; id: 1; age: 30 }>; // [‘name’, 1]

type isNever = ElementTypeOfObj; // never

}

在示例中的第 1 行,我们定义了入参是 T 的泛型 ElementTypeOfObj,并通过两个 infer 类型推断来获取入参 name、id 属性的类型。在第 3 行,因为入参是包含 name、id 属性的接口类型,所以提取到了元组类型 [‘name’, 1]。而在第 4 行,因为入参 number 不满足三元运算中的条件判断,所以返回了 never。

索引访问类型

索引访问类型其实更像是获取物料的方式,首先我们可以通过属性名、索引、索引签名按需提取对象(接口类型)任意成员的类型注意:只能使用 [索引名] 的语法),如下示例。

interface MixedObject {

  1. animal: {
  2. type: 'animal' | 'dog' | 'cat';
  3. age: number;
  4. };
  5. [name: number]: {
  6. type: string;
  7. age: number;
  8. nickname: string;
  9. };
  10. [name: string]: {
  11. type: string;
  12. age: number;
  13. };

}

type animal = MixedObject[‘animal’];

type animalType = MixedObject[‘animal’][‘type’];

type numberIndex = MixedObject[number];

type numberIndex0 = MixedObject[0];

type stringIndex = MixedObject[string];

type stringIndex0 = MixedObject[‘string’];

在示例的第 16 行,我们通过 ‘animal’ 索引获取了 MixedObject 接口的 animal 属性的类型。在第 17 行,我们通过多级属性索引获取了更深层级 type 属性的类型。

然后,在第 18 行、第 19 行,我们通过 number 类型索引签名和数字索引 0 获取了第 6~10 行定义的同一个接口类型。

最后,在第 20 行、第 21 行,我们通过 string 类型索引签名和字符串字面量索引 ‘string’ 获取了第 11~14 行定义的同一个接口类型(回顾 7 讲)。

keyof

其次,我们还可以使用 keyof 关键字提取对象属性名、索引名、索引签名的类型,如下示例:

type MixedObjectKeys = keyof MixedObject; // string | number

type animalKeys = keyof animal; // ‘type’ | ‘age’

type numberIndexKeys = keyof numberIndex; // “type” | “age” | “nickname”

在示例中的第 1 行,我们使用 keyof 提取了 MixedObject 接口的属性和索引签名,它是由 string、number 和 ‘animal’ 类型组成的联合类型,缩减之后就是 string | number 联合类型。在第 2 行,我们提取了 ‘type’ 和 ‘age’ 字符串字面量类型组成的联合类型。在第 3 行,我们提取了 ‘type’、’age’ 和 ‘nickname’ 组成的联合类型。

typeof

最后介绍的操作符物料是 typeof。

如果我们在表达式上下文中使用 typeof,则是用来获取表达式值的类型,如果在类型上下文中使用,则是用来获取变量或者属性的类型。当然,在 TypeScript 中,typeof 的主要用途是在类型上下文中获取变量或者属性的类型,下面我们通过一个具体示例来理解一下。

{

let StrA = ‘a’;

const unions = typeof StrA; // unions 类型是 “string” | “number” | “bigint” | “boolean” | “symbol” | “undefined” | “object” | “function”

const str: typeof StrA = ‘string’; // strs 类型是 string

type DerivedFromStrA = typeof StrA; // string

}

在示例中的第 3 行,typeof 作用在表达式上下文中,获取的是 StrA 值的类型,因为与静态类型上下文无关,所以变量 unions 的类型是 ‘string’、’number’ 等字符串字面量组成的联合类型。

而在第 4 行,typeof 作用在类型上下文中,提取的是变量 StrA 的类型,因为第 1 行推断出来 StrA 的类型是 string,所以提取的类型、变量 str 的类型也是 string。

当然,我们也可以使用一个类型别名专门接收从变量 StrA 提取的类型,比如示例中的第 5 行,类型别名 DerivedFromStrA 的类型是 string。

对于任何未显式添加类型注解或值与类型注解一体(**比如函数、类)的变量或属性**,我们都可以使用 typeof 提取它们的类型,这是一个十分方便、有用的设计,如下示例:

const animal = {

  1. id: 1,
  2. name: 'animal'

};

type Animal = typeof animal;

const animalFun = () => animal;

type AnimalFun = typeof animalFun;

在示例中的第 5 行、第 7 行,我们使用 typeof 提取了对象 animal 和函数 animaFun 的类型。

映射类型

我们可以使用索引签名语法和 in 关键字限定对象属性的范围,如下示例:

type SpecifiedKeys = ‘id’ | ‘name’;

type TargetType = {

  1. **[key in SpecifiedKeys]: any;**

}; // { id: any; name: any; }

type TargetGeneric = {

  1. [key in O]: any;

}

type TargetInstance = TargetGeneric; // { id: any; name: any; }

在示例中的第 1 行,我们定义了联合类型 SpecifiedKeys,并在第 3 行、第 6 行使用 in 限定了 AnimalNormal 对象和泛型 AnimalGeneric 的属性必须是 SpecifiedKeys 的成员,所以最终第 2 行、第 8 行得到的类型都是 { id: any; name: any; }。

注意:我们**只能在类型别名定义中使用 in,**如果在接口中使用,则会提示一个 ts(1169) 的错误,如下示例第 2 行所示。

interface ITargetInterface {

  1. [key in SpecifiedKeys]: any; // ts(1169)

}

在定义类型时,我们可以组合使用 in 和 keyof,并基于已有的类型创建一个新类型,使得新类型与已有类型保持一致的只读、可选特性,这样的泛型被称之为映射类型。

注意:in 和 keyof 也只能在类型别名定义中组合使用。

下面我们看一个具体的示例:

interface SourceInterface {

  1. readonly id: number;
  2. name?: string;

}

type TargetType = {

  1. [key in keyof SourceInterface]: SourceInterface[key];

}; // { readonly id: number; name?: string | undefined }

type TargetGenericType = {

  1. [key in keyof S]: S[key];

};

type TargetInstance = TargetGenericType; // { readonly id: number; name?: string | undefined }

在示例中的第 6 行、第 9 行,我们使用 in 和 keyof,以及基于接口类型 SourceInterface 和泛型入参 S 分别创建了一个新类型,最终第 5 行的 TargetType、第 11 行的TargetInstance 也获得了只读的 id 属性和可选的 name 属性。

同样,我们可以在映射类型中使用 readonly、? 修饰符来描述属性的可读性、可选性,也可以在修饰符前添加 +、- 前缀表示添加、移除指定修饰符(默认是 +、添加),如下示例:

type TargetGenericTypeReadonly = {

  1. readonly [key in keyof S]: S[key];

}

type TargetGenericTypeReadonlyInstance = TargetGenericTypeReadonly; // { readonly id: number; readonly name?: string | undefined }

type TargetGenericTypeOptional = {

  1. [key in keyof S]?: S[key];

}

type TargetGenericTypeOptionalInstance = TargetGenericTypeOptional; // { readonly id?: number; readonly name?: string | undefined }

type TargetGenericTypeRemoveReadonly = {

  1. -readonly [key in keyof S]: S[key];

}

type TargetGenericTypeRemoveReadonlyInstance = TargetGenericTypeRemoveReadonly; // { id: number; name?: string | undefined }

type TargetGenericTypeRemoveOptional = {

  1. [key in keyof S]-?: S[key];

}

type TargetGenericTypeRemoveOptionalInstance = TargetGenericTypeRemoveOptional; // { readonly id: number; name: string }

在示例中的第 1~3 行,我们给所有属性添加了 readonly 修饰符,所以第 4 行得到的类型是 { readonly id: number; readonly name?: string | undefined }。

在第 5~7 行,我们给所有属性添加了 ? 可选修饰符,所以第 8 行得到的类型是 { readonly id?: number; readonly name?: string | undefined }。

在第 9~11 行,我们通过 -readonly 移除了只读修饰符,所以第 12 行得到的类型是 { id: number; name?: string | undefined }。

在第 13~15 行,我们通过 -? 移除了可选修饰符,所以第 16 行得到的类型是 { readonly id: number; name: string }。

使用 as 重新映射 key

穿越一下,自 TypeScript 4.1 起,我们可以在映射类型的索引签名中使用类型断言,如下示例(TypeScript 4.1 以下则会提示错误):

type TargetGenericTypeAssertiony = {

  1. [key in keyof S **as Exclude<key, 'id'>**]: S[key];

}

type TargetGenericTypeAssertionyInstance = TargetGenericTypeAssertiony; // { name?: string | undefined; }

在示例中的第 2 行,我们将 key 断言为排除 ‘id’ 以外的联合类型,所以第 4 行实例得到的类型是 { name?: string | undefined; }。

以上就是自定义工具类型所需要的物料。将物料与官方内置工具类型结合起来,我们就可以愉快地造轮子了。

造轮子

其实,在前面的示例中,我们已经实现了例如 isAssertable、isSubTyping 等自定义工具类型,接下来再介绍几个第三方自定义工具和类型的实现。

Exclude

我们再来复习一下 14 讲中介绍的官方自带工具类型 Exclude,它用来排除入参 T 内是入参 U 子类型的成员类型,如下示例:

type ExcludeSpecifiedNumber = Exclude<1 | 2, 1>; // 2

type ExcludeSpecifiedString = Exclude<’id’ | ‘name’, ‘id’>; // ‘name

type ExcludeSpecifiedBoolean = Exclude; // false

在示例中的第 1~3 行,排除指定成员类型 1、’id’、true 之后得到的类型是 2、’name’、false。

我们可以在 VS Code 中使用快捷键 Ctrl/Command + 点击 Exclude 查看它在 node_modules/typescript/lib/lib.es5.d.ts 中的定义,代码实现如下:

type Exclude = T extends U ? never : T;

这里明显利用了分配条件类型的特性,所以入参 T 会被拆解为成员类型。如果成员类型是入参 U 的子类型,则返回 never,否则返回成员类型。

当入参分别是联合类型 1 | 2 与字面量类型 1,因为联合类型被拆解后的**成员 1 是 1 的子类型,而成员 2 不是 1 的子类型,所以返回的是联合类型 never | 2由于 never 是 2 的子类型,最终类型缩减后得到的就是 2。**

接下来我们开始介绍一个自定义工具类型 ReturnTypeOfResolved。

ReturnTypeOfResolved

ReturnTypeOfResolved 和官方 ReturnType 的区别:如果**入参 F 的返回类型是泛型 Promise 的实例**,则返回 Promise 接收的入参。

我们可以借鉴 ReturnType 的定义实现 ReturnTypeOfResolved,如下示例:

// type ReturnType any> = T extends (…args: any) => infer R ? R : any;

type ReturnTypeOfResolved any> = F extends (…args: any[]) => Promise ? R : ReturnType;

type isNumber = ReturnTypeOfResolved<() => number>; // number

type isString = ReturnTypeOfResolved<() => Promise>; // string

示例中第 1 行注释的代码是官方工具类型 ReturnType 的实现,第 2 行我们自定义了一个泛型 ReturnTypeOfResolved,并约束了入参 F 必须是函数类型。当入参 F 的返回值是 Promise 类型,通过条件类型,我们推断 infer 获取了 Promise 入参类型,所以第 3 行返回的是入参函数返回值类型 number,第 4 行返回的是入参函数返回 Promise 入参类型 string。

Merge

接下来我们再基于映射类型将类型入参 A 和 B 合并为一个类型的泛型 Merge,如下示例:

type Merge = {

  1. [key in keyof A | keyof B]: key extends keyof A
  2. ? key extends keyof B
  3. ? A[key] | B[key]
  4. : A[key]
  5. : key extends keyof B
  6. ? B[key]
  7. : never;

};

type Merged = Merge<{ id: number; name: string }, { id: string; age: number }>;

在第 2 行,我们限定了返回类型属性 key 为入参 A、B 属性的联合类型。当 key 为 A、B 的同名属性,合并后的属性类型为联合类型 A[key] | B[key](第 2~4 行);当 key 为 A 或者 B 的属性,合并后的属性类型为 A[key] 或者 B[key](第 5~7 行)。

最后,我们在第 10 行使用了 Merge 合并两个接口类型,从而得到了 { id: number | string; name: string; age: number }。

Equal

我们再来实现一个自定义工具类型 Equal,它可以用来判断入参 S 和 T 是否是相同的类型。如果相同,则返回布尔字面量类型 true,否则返回 false。

此时,我们很容易想到,如果 S 是 T 的子类型且 T 也是 S 的子类型,则说明 S 和 T 是相同的类型,所以 Equal 的实现似乎是这样的:

type EqualV1 = S extends T ? T extends S ? true : false : false;

type ExampleV11 = EqualV1<1 | number & {}, number>; // true but boolean

type ExampleV12 = EqualV1; // true but never

在示例中的第 1 行,我们实现了泛型 EqualV1;第 2 行中的第一个入参是联合类型,因为分配条件类型的设定,所以第一个类型入参被拆解,最终返回类型 boolean(实际上是联合类型 true | false)。同样,在第 3 行中,当入参是 never,则返回类型 never。因此,EqualV1 并不符合我们的预期。

此时,我们需要使用 [] 解除条件分配类型和 never “陷阱”,确保自定义泛型仅返回 true 或者 false,所以前面示例的改良版本 EqualV2 如下:

type EqualV2 = [S] extends [T] ? [T] extends [S] ? true : false : false;

type ExampleV21 = EqualV2<1 | number & {}, number>; // true

type ExampleV22 = EqualV2; // true

type ExampleV23 = EqualV2; // false but true

在示例中的第 2 行、第 3 行,虽然我们解决了联合类型和 never 的问题,但是还是无法区分万金油类型 any 和其他类型。在第 4 行,当入参是 any 和 number,预期应该返回 false,却返回了 true。

这时,我们还需要使用一个可以能识别 any 的改良版 EqualV3 如下:

type IsAny = 0 extends (1 & T) ? true : false;

type EqualV3 = IsAny extends true

  1. ? IsAny<T> extends true
  2. ? true
  3. : false
  4. : IsAny<T> extends true
  5. ? false
  6. : [S] extends [T]
  7. ? [T] extends [S]
  8. ? true
  9. : false
  10. : false;

type ExampleV31 = EqualV3<1 | number & {}, number>; // true but false got

type ExampleV32 = EqualV3; // true

type ExampleV34 = EqualV3; // true

type ExampleV33 = EqualV3; // false

type ExampleV35 = EqualV3; // false

在示例中的第 1 行,我们定义了可以区分 any 和其他类型的泛型 IsAny,因为只有 any 和 1 交叉得到的类型(any)是 0 的父类型,所以如果入参是 any 则会返回 true,否则返回 false。

在第 2~7 行,我们定义了 EqualV3(首先特殊处理了类型入参 S 和 T 至少有一个是 any 的情况),当 S 和 T 都是 any 才返回 true,否则返回 false。因此,在第 15~17 行,EqualV3 是可以区分 any 和其他类型的。

在第 8 ~12 行,我们复用了 EqualV2 的逻辑,并通过 [] 解除了条件分配类型,所以第 13~14 行 EqualV3 可以判断出联合类型 1 | number & {} 和 number、never 和 never 是相同的类型。

至此,我们造的第一个轮子 Equal(实际上,用来区分 any 类型的泛型 IsAny 也可以算一个轮子)基本可以正确地区分大多数类型了。