跳到主要内容

类型编程:打造属于自己的工具类型

类型物料

泛型

使用泛型进行变量抽离、逻辑封装其实就是在造类型的轮子

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>; // true
type isYY2 = isSubTyping<'string', string>; // true
type isZZ2 = isSubTyping<true, boolean>; // true

示例中的第 1 行封装出来的工具泛型其实是我们在 上文中使用到的工具类型 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>; // true
type isStrAssertable = isAssertable<string, 'string'>; // 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)

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

看一个具体的示例:

type BooleanOrString = string | boolean;
type StringOrNumberArray<E> = E extends string | number ? E[] : E;
type WhatIsThis = StringOrNumberArray<BooleanOrString>; // 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> = [E] extends [string | number] ? E[] : E;
type WhatIsThis = StringOrNumberArray<string | boolean>; // 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>; // 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>; // never
type ThisIsNotNeverX = UselessNeverX<never, string>; // string[]
type ThisIsNotNeverY = UselessNeverY<never, string>; // never[]
type ThisIsNotNeverZ = UselessNeverZ<never>; // 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[]>; // number
type isNever = ElementTypeOfArray<number>; // never
}

在示例中的第 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 }>; // ['name', 1]
type isNever = ElementTypeOfObj<number>; // never
}

在示例中的第 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 行定义的同一个接口类型

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,则是用来获取表达式值的类型,如果在类型上下文中使用,则是用来获取变量或者属性的类型。当然,在 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 = {
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;
}; // { id: any; name: any; }
type TargetGeneric<O extends string | number | symbol> = {
[key in O]: any;
}
type TargetInstance = TargetGeneric<SpecifiedKeys>; // { id: any; name: any; }

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

interface ITargetInterface {
[key in SpecifiedKeys]: any; // ts(1169)
}

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

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

interface SourceInterface {
readonly id: number;
name?: string;
}
type TargetType = {
[key in keyof SourceInterface]: SourceInterface[key];
}; // { readonly id: number; name?: string | undefined }
type TargetGenericType<S> = {
[key in keyof S]: S[key];
};
type TargetInstance = TargetGenericType<SourceInterface>; // { readonly id: number; name?: string | undefined }

在示例中的第 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>; // { readonly id: number; readonly name?: string | undefined }
type TargetGenericTypeOptional<S> = {
[key in keyof S]?: S[key];
}
type TargetGenericTypeOptionalInstance = TargetGenericTypeOptional<SourceInterface>; // { readonly id?: number; readonly name?: string | undefined }
type TargetGenericTypeRemoveReadonly<S> = {
-readonly [key in keyof S]: S[key];
}
type TargetGenericTypeRemoveReadonlyInstance = TargetGenericTypeRemoveReadonly<SourceInterface>; // { id: number; name?: string | undefined }
type TargetGenericTypeRemoveOptional<S> = {
[key in keyof S]-?: S[key];
}
type TargetGenericTypeRemoveOptionalInstance = TargetGenericTypeRemoveOptional<SourceInterface>; // { 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>; // { name?: string | undefined; }

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

造轮子

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

Exclude

type ExcludeSpecifiedNumber = Exclude<1 | 2, 1>; // 2
type ExcludeSpecifiedString = Exclude<'id' | 'name', 'id'>; // 'name
type ExcludeSpecifiedBoolean = Exclude<boolean, true>; // 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, U> = T extends U ? never : T;

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

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

ReturnTypeOfResolved

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

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

// type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;
type ReturnTypeOfResolved<F extends (...args: any) => any> = F extends (...args: any[]) => Promise<infer R> ? R : ReturnType<F>;
type isNumber = ReturnTypeOfResolved<() => number>; // number
type isString = ReturnTypeOfResolved<() => Promise<string>>; // string

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

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](第 24 行);当 key 为 A 或者 B 的属性,合并后的属性类型为 A[key] 或者 B[key](第 57 行)。

Equal

此时,我们很容易想到,如果 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>; // true but boolean
type ExampleV12 = EqualV1<never, never>; // true but 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>; // true
type ExampleV22 = EqualV2<never, never>; // true
type ExampleV23 = EqualV2<any, number>; // false but true

在示例中的第 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>; // true but false got
type ExampleV32 = EqualV3<never, never>; // true
type ExampleV34 = EqualV3<any, any>; // true
type ExampleV33 = EqualV3<any, number>; // false
type ExampleV35 = EqualV3<never, any>; // false

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

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

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

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