Type script 入门实战笔记 - 快手资深前端技术专家,快手轻雀协作前端负责人 - 拉勾教育
14 讲我们学习了常见 TypeScript 官方内置的工具类型(官方轮子),它们的本质就是自定义的复杂类型构造器(确切地讲是泛型)。这一讲我们就来学习一下如何自己造轮子,并剖析一些常见第三方轮子的实现。
学习建议:使用 VS Code 新建一个 15.ts 文件,尝试这一讲中出现的所有示例。
类型物料
在正式造轮子之前,我们先来熟悉一下即将用到的物料,这可能涉及前面每一讲中的知识点和一些新语法。如果你连泛型特性都记不清的话,那么请至少从 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, Par> = Child extends Par ? true : false;
type isXX2 = isSubTyping<1, number>;
type isYY2 = isSubTyping<'string', string>;
type isZZ2 = isSubTyping<true, boolean>;
示例中的第 1 行封装出来的工具泛型其实是我们在 12 讲中使用到的工具类型 isSubTyping,如果类型入参 Child 是 Par 的子类型,则返回布尔字面量类型 true,否则返回 false。这样,我们就可以使用 isSubTyping 判断其他任意两个类型之间的子类型关系了。
比如示例中的第 2~4 行,因为 1 和 number、’string’ 和 string、true 和 boolean 都是子类型和父类型的关系,所以返回的都是布尔字面量类型 true。
条件类型
如我们在泛型中提到,TypeScript 支持使用三元运算的条件类型,它可以根据 ?前面的条件判断返回不同的类型。同时,三元运算还支持嵌套。
在三元运算的条件判断逻辑中,它主要使用 extends 关键字判断两个类型的子类型关系,如下代码所示:
type isSubTyping<Child, Par> = Child extends Par ? true : false;
type isAssertable<T, S> = T extends S ? true : S extends T ? true : false;
type isNumAssertable = isAssertable<1, number>;
type isStrAssertable = isAssertable<string, 'string'>;
type isNotAssertable = isAssertable<1, boolean>;
以上示例,除了在第 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> = E extends string | number ? E[] : E;
type WhatIsThis = StringOrNumberArray<BooleanOrString>;
type BooleanOrStringGot = BooleanOrString extends string | number ? BooleanOrString[] : BooleanOrString;
在示例中的第 3 行, string 和 boolean 组成的联合类型 BooleanOrString 作为泛型 StringOrNumberArray 入参的时候,则会被拆解成 string 和 boolean 这两个独立的类型,再通过 extends 关键字判断是否是 string | number 类型的子类型。
因为 string 是子集,而 boolean 不是,所以最终我们得到的 WhatIsThis 的类型是 boolean | string[]。但是,在非泛型条件类型中(示例中的第 4 行),因为 BooleanOrString 被当成了一个整体对待,所以 BooleanOrStringGot 的类型是 string | boolean。
同样,通过某些手段强制类型入参被当成一个整体,也可以解除类型分配,如下示例:
type StringOrNumberArray<E> = [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;
type GetNever = StringOrNumberArray<never>;
在上述示例中的第 1 行,因为 never 是所有类型的子类型,自然也是 number 的子类型,所以返回的是 number 类型的数组;在第 2 行传入 never 作为入参来实例化前面定义的泛型 StringOrNumberArray 时,返回的类型却是 never,而不是 number[]。
你要知道,泛型 StringOrNumberArray 的实现与示例第 1 行 “=” 右侧的逻辑并没有任何区别(除 never 被抽离成入参之外)。这是因为 never 是不能分配的底层类型,如果作为入参以原子形式出现在条件判断 extends 关键字左侧,则实例化得到的类型也是 never。
下面看一个具体的示例:
type UsefulNeverX<T> = T extends {} ? T[] : [];
type UselessNeverX<T, S> = S extends {} ? S[] : [];
type UselessNeverY<T, S> = S extends {} ? T[] : [];
type UselessNeverZ<T> = [T] extends [{}] ? T[] : [];
type ThisIsNeverX = UsefulNeverX<never>;
type ThisIsNotNeverX = UselessNeverX<never, string>;
type ThisIsNotNeverY = UselessNeverY<never, string>;
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> = T extends (infer E)[] ? E : never;
type isNumber = ElementTypeOfArray<number[]>;
type isNever = ElementTypeOfArray<number>;
}
在示例中的第 1 行,我们定义了接收入参 T 的泛型 ElementTypeOfArray,并在三元运算的条件判断中,通过 (infer E)[] 定义了一个有对元素类型推断参数 E 的数组。当入参 T 满足是 (infer E)[] 数组类型的子类型的条件,则返回参数 E,即数组元素类型,所以在第 3 行传入 number[] 入参时返回的是 number 类型,而传入 number 时返回的则是 never。
我们还可以通过 infer 创建任意个类型推断参数,以此获取任意的成员类型,如下示例:
{
type ElementTypeOfObj<T> = T extends { name: infer E; id: infer I } ? [E, I] : never;
type isArray = ElementTypeOfObj<{ name: 'name'; id: 1; age: 30 }>;
type isNever = ElementTypeOfObj<number>;
}
在示例中的第 1 行,我们定义了入参是 T 的泛型 ElementTypeOfObj,并通过两个 infer 类型推断来获取入参 name、id 属性的类型。在第 3 行,因为入参是包含 name、id 属性的接口类型,所以提取到了元组类型 [‘name’, 1]。而在第 4 行,因为入参 number 不满足三元运算中的条件判断,所以返回了 never。
索引访问类型
索引访问类型其实更像是获取物料的方式,首先我们可以通过属性名、索引、索引签名按需提取对象(接口类型)任意成员的类型(注意:只能使用 [索引名] 的语法),如下示例。
interface MixedObject {
animal: {
type: 'animal' | 'dog' | 'cat';
age: number;
};
[name: number]: {
type: string;
age: number;
nickname: string;
};
[name: string]: {
type: string;
age: number;
};
}
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;
type animalKeys = keyof animal;
type numberIndexKeys = keyof numberIndex;
在示例中的第 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;
const str: typeof StrA = 'string';
type DerivedFromStrA = typeof StrA;
}
在示例中的第 3 行,typeof 作用在表达式上下文中,获取的是 StrA 值的类型,因为与静态类型上下文无关,所以变量 unions 的类型是’string’、’number’ 等字符串字面量组成的联合类型。
而在第 4 行,typeof 作用在类型上下文中,提取的是变量 StrA 的类型,因为第 1 行推断出来 StrA 的类型是 string,所以提取的类型、变量 str 的类型也是 string。
当然,我们也可以使用一个类型别名专门接收从变量 StrA 提取的类型,比如示例中的第 5 行,类型别名 DerivedFromStrA 的类型是 string。
对于任何未显式添加类型注解或值与类型注解一体(比如函数、类)的变量或属性,我们都可以使用 typeof 提取它们的类型,这是一个十分方便、有用的设计,如下示例:
const animal = {
id: 1,
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 = {
[key in SpecifiedKeys]: any;
};
type TargetGeneric<O extends string | number | symbol> = {
[key in O]: any;
}
type TargetInstance = TargetGeneric<SpecifiedKeys>;
在示例中的第 1 行,我们定义了联合类型 SpecifiedKeys,并在第 3 行、第 6 行使用 in 限定了 AnimalNormal 对象和泛型 AnimalGeneric 的属性必须是 SpecifiedKeys 的成员,所以最终第 2 行、第 8 行得到的类型都是 {id: any; name: any;}。
注意:我们只能在类型别名定义中使用 in,如果在接口中使用,则会提示一个 ts(1169) 的错误,如下示例第 2 行所示。
interface ITargetInterface {
[key in SpecifiedKeys]: any;
}
在定义类型时,我们可以组合使用 in 和 keyof,并基于已有的类型创建一个新类型,使得新类型与已有类型保持一致的只读、可选特性,这样的泛型被称之为映射类型。
注意:in 和 keyof 也只能在类型别名定义中组合使用。
下面我们看一个具体的示例:
interface SourceInterface {
readonly id: number;
name?: string;
}
type TargetType = {
[key in keyof SourceInterface]: SourceInterface[key];
};
type TargetGenericType<S> = {
[key in keyof S]: S[key];
};
type TargetInstance = TargetGenericType<SourceInterface>;
在示例中的第 6 行、第 9 行,我们使用 in 和 keyof,以及基于接口类型 SourceInterface 和泛型入参 S 分别创建了一个新类型,最终第 5 行的 TargetType、第 11 行的 TargetInstance 也获得了只读的 id 属性和可选的 name 属性。
同样,我们可以在映射类型中使用 readonly、? 修饰符来描述属性的可读性、可选性,也可以在修饰符前添加 +、- 前缀表示添加、移除指定修饰符(默认是 +、添加),如下示例:
type TargetGenericTypeReadonly<S> = {
readonly [key in keyof S]: S[key];
}
type TargetGenericTypeReadonlyInstance = TargetGenericTypeReadonly<SourceInterface>;
type TargetGenericTypeOptional<S> = {
[key in keyof S]?: S[key];
}
type TargetGenericTypeOptionalInstance = TargetGenericTypeOptional<SourceInterface>;
type TargetGenericTypeRemoveReadonly<S> = {
-readonly [key in keyof S]: S[key];
}
type TargetGenericTypeRemoveReadonlyInstance = TargetGenericTypeRemoveReadonly<SourceInterface>;
type TargetGenericTypeRemoveOptional<S> = {
[key in keyof S]-?: S[key];
}
type TargetGenericTypeRemoveOptionalInstance = TargetGenericTypeRemoveOptional<SourceInterface>;
在示例中的第 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<S> = {
[key in keyof S as Exclude<key, 'id'>]: S[key];
}
type TargetGenericTypeAssertionyInstance = TargetGenericTypeAssertiony<SourceInterface>;
在示例中的第 2 行,我们将 key 断言为排除’id’ 以外的联合类型,所以第 4 行实例得到的类型是 {name?: string | undefined;}。
以上就是自定义工具类型所需要的物料。将物料与官方内置工具类型结合起来,我们就可以愉快地造轮子了。
造轮子
其实,在前面的示例中,我们已经实现了例如 isAssertable、isSubTyping 等自定义工具类型,接下来再介绍几个第三方自定义工具和类型的实现。
Exclude
我们再来复习一下 14 讲中介绍的官方自带工具类型 Exclude
type ExcludeSpecifiedNumber = Exclude<1 | 2, 1>;
type ExcludeSpecifiedString = Exclude<'id' | 'name', 'id'>;
type ExcludeSpecifiedBoolean = Exclude<boolean, true>;
在示例中的第 1~3 行,排除指定成员类型 1、’id’、true 之后得到的类型是 2、’name’、false。
我们可以在 VS Code 中使用快捷键 Ctrl/Command + 点击 Exclude 查看它在 node_modules/typescript/lib/lib.es5.d.ts 中的定义,代码实现如下:
type Exclude<T, U> = 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 ReturnTypeOfResolved<F extends (...args: any) => any> = F extends (...args: any[]) => Promise<infer R> ? R : ReturnType<F>;
type isNumber = ReturnTypeOfResolved<() => 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<A, B> = {
[key in keyof A | keyof B]: key extends keyof A
? key extends keyof B
? A[key] | B[key]
: A[key]
: key extends keyof B
? B[key]
: 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, T> = S extends T ? T extends S ? true : false : false;
type ExampleV11 = EqualV1<1 | number & {}, number>;
type ExampleV12 = EqualV1<never, never>;
在示例中的第 1 行,我们实现了泛型 EqualV1;第 2 行中的第一个入参是联合类型,因为分配条件类型的设定,所以第一个类型入参被拆解,最终返回类型 boolean(实际上是联合类型 true | false)。同样,在第 3 行中,当入参是 never,则返回类型 never。因此,EqualV1 并不符合我们的预期。
此时,我们需要使用 [] 解除条件分配类型和 never “陷阱”,确保自定义泛型仅返回 true 或者 false,所以前面示例的改良版本 EqualV2 如下:
type EqualV2<S, T> = [S] extends [T] ? [T] extends [S] ? true : false : false;
type ExampleV21 = EqualV2<1 | number & {}, number>;
type ExampleV22 = EqualV2<never, never>;
type ExampleV23 = EqualV2<any, number>;
在示例中的第 2 行、第 3 行,虽然我们解决了联合类型和 never 的问题,但是还是无法区分万金油类型 any 和其他类型。在第 4 行,当入参是 any 和 number,预期应该返回 false,却返回了 true。
这时,我们还需要使用一个可以能识别 any 的改良版 EqualV3 如下:
type IsAny<T> = 0 extends (1 & T) ? true : false;
type EqualV3<S, T> = IsAny<S> extends true
? IsAny<T> extends true
? true
: false
: IsAny<T> extends true
? false
: [S] extends [T]
? [T] extends [S]
? true
: false
: false;
type ExampleV31 = EqualV3<1 | number & {}, number>;
type ExampleV32 = EqualV3<never, never>;
type ExampleV34 = EqualV3<any, any>;
type ExampleV33 = EqualV3<any, number>;
type ExampleV35 = EqualV3<never, any>;
在示例中的第 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 也可以算一个轮子)基本可以正确地区分大多数类型了。
插播一道思考题:尝试找一个 ExampleV3 不能正确分区类型的反例,并改良 ExampleV3。
小结与预告
这一讲我们介绍了很多的知识点和特性,在学习时,你可以结合以上知识点、特性、工具类型,以及官方工具类型源码实现剖析。只有这样,我们才能构造出较为复杂的工具类型。
这里插播一道思考题:什么是分配条件类型?欢迎你在留言区进行互动、交流。
从 16 讲开始,我们将学习本课程的第三部分 TypeScript 实践的经验和建议,敬请期待。
最后,如果你觉得本专栏有价值,欢迎分享给更多好友哦~