17.内置工具类型进阶:类型编程进阶

10/9/2023

此前,我们已经了解了 TypeScript 中内置工具类型的实现原理,以及它们的扩展方向。这一节,我们会在这些基础上逐一实现这些扩展方向。

需要说明的是,本节中的工具类型会更加复杂和烧脑一些,你需要确保已经完全掌握了这一节前的绝大部分知识再来学习本节内容。如果在学习过程中发现有知识点的缺失,可以先回到前面的章节复习、巩固,再学不迟。

另外,这一节中介绍的工具类型绝大部分是具有实际应用场景的,如果你发现某一个工具类型恰好匹配了你的需求,不妨在自己的项目中复制一份。随着不断的积累,你会发现,你拥有了一个最适合自己的工具类型合集!

本节代码见:Advanced Builtin Tool Types (opens new window)

# 属性修饰进阶

在内置工具类型一节中,对属性修饰工具类型的进阶主要分为这么几个方向:

  • 深层的属性修饰;
  • 基于已知属性的部分修饰,以及基于属性类型的部分修饰。

首先是深层属性修饰,还记得我们在 infer 关键字一节首次接触到递归的工具类型吗?

type PromiseValue<T> = T extends Promise<infer V> ? PromiseValue<V> : T;
1

可以看到,此时我们只是在条件类型成立时,再次调用了这个工具类型而已。在某一次递归到条件类型不成立时,就会直接返回这个类型值。那么对于 Partial、Required,其实我们也可以进行这样地处理:

export type DeepPartial<T extends object> = {
  [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};
1
2
3

简单起见,我们直接使用了 object 作为泛型约束与条件,这意味着也有可能传入函数、数组等类型。但毕竟我们对这个类型知根知底,就可以假设只会传入对象结构,因此也只需要对对象类型进行处理了。

为了更直观地验证它的效果,我们使用 tsd (opens new window) 这一工具类型单元测试库来进行验证,效果大概是这样:

import { expectType } from 'tsd';

type DeepPartialStruct = DeepPartial<{
  foo: string;
  nested: {
    nestedFoo: string;
    nestedBar: {
      nestedBarFoo: string;
    };
  };
}>;

expectType<DeepPartialStruct>({
  foo: 'bar',
  nested: {},
});

expectType<DeepPartialStruct>({
  nested: {
    nestedBar: {},
  },
});

expectType<DeepPartialStruct>({
  nested: {
    nestedBar: {
      nestedBarFoo: undefined,
    },
  },
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

在 expectType 的泛型坑位中传入一个类型,然后再传入一个值,就可以验证这个值是否符合泛型类型了。

类似的,我们还可以实现其他进行递归属性修饰的工具类型,展示如下:

export type DeepPartial<T extends object> = {
  [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};

export type DeepRequired<T extends object> = {
  [K in keyof T]-?: T[K] extends object ? DeepRequired<T[K]> : T[K];
};

// 也可以记作 DeepImmutable
export type DeepReadonly<T extends object> = {
  readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};

export type DeepMutable<T extends object> = {
  -readonly [K in keyof T]: T[K] extends object ? DeepMutable<T[K]> : T[K];
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

另外,在内置工具类型一节的结构工具类型中,存在一个从联合类型中剔除 null | undefined 的工具类型 NonNullable:

type NonNullable<T> = T extends null | undefined ? never : T;
1

在对象结构中我们也常声明类型为 string | null 的形式,代表了“这里有值,但可能是空值”。此时,我们也可以将其等价为一种属性修饰(Nullable 属性,前面则是 Optional / Readonly 属性)。因此,我们也可以像访问性修饰工具类型那样,实现一个 DeepNonNullable 来递归剔除所有属性的 null 与 undefined:

type NonNullable<T> = T extends null | undefined ? never : T;

export type DeepNonNullable<T extends object> = {
  [K in keyof T]: T[K] extends object
    ? DeepNonNullable<T[K]>
    : NonNullable<T[K]>;
};
1
2
3
4
5
6
7

当然,就像 Partial 与 Required 的关系一样,DeepNonNullable 也有自己的另一半:DeepNullable:

export type Nullable<T> = T | null;

export type DeepNullable<T extends object> = {
  [K in keyof T]: T[K] extends object ? DeepNullable<T[K]> : Nullable<T[K]>;
};
1
2
3
4
5

需要注意的是,DeepNullable 和 DeepNonNullable 需要在开启 --strictNullChecks 下才能正常工作。

搞定了递归属性修饰,接着就是基于已知属性进行部分修饰了。这其实也很简单。你想,如果我们要让一个对象的三个已知属性为可选的,那只要把这个对象拆成 A、B 两个对象结构,分别由三个属性和其他属性组成。然后让对象 A 的属性全部变为可选的,和另外一个对象 B 组合起来,不就行了吗?

拆开来描述一下这句话,看看这里都用到了哪些知识:

  • 拆分对象结构,那不就是内置工具类型一节中讲到的结构工具类型,即 Pick 与 Omit?
  • 三个属性的对象全部变为可选,那不就是属性修饰?岂不是可以直接用上面刚学到的递归属性修饰
  • 组合两个对象类型,也就意味着得到一个同时符合这两个对象类型的新结构,那不就是交叉类型

分析出了需要用到的工具和方法,那执行起来就简单多了。这也是使用最广泛的一种类型编程思路:将复杂的工具类型,拆解为由基础工具类型、类型工具的组合

直接来看基于已知属性的部分修饰,MarkPropsAsOptional 会将一个对象的部分属性标记为可选:

export type MarkPropsAsOptional<
  T extends object,
  K extends keyof T = keyof T
> = Partial<Pick<T, K>> & Omit<T, K>;
1
2
3
4

T 为需要处理的对象类型,而 K 为需要标记为可选的属性。由于此时 K 必须为 T 内部的属性,因此我们将其约束为 keyof T,即对象属性组成的字面量联合类型。同时为了让它能够直接代替掉 Partial,我们为其指定默认值也为 keyof T,这样在不传入第二个泛型参数时,它的表现就和 Partial 一致,即全量的属性可选。

而其组成中,Partial<Pick<T, K>> 为需要标记为可选的属性组成的对象子结构,Omit<T, K> 则为不需要处理的部分,使用交叉类型将其组合即可。我们验证下效果:

type MarkPropsAsOptionalStruct = MarkPropsAsOptional<
  {
    foo: string;
    bar: number;
    baz: boolean;
  },
  'bar'
>;
1
2
3
4
5
6
7
8

img

啊哦,这可不好看出来具体效果。此时我们可以引入一个辅助的工具类型,我称其为 Flatten,对于这种交叉类型的结构,Flatten 能够将它展平为单层的对象结构。而它的实现也很简单,就是复制一下结构罢了:

export type Flatten<T> = { [K in keyof T]: T[K] };

export type MarkPropsAsOptional<
  T extends object,
  K extends keyof T = keyof T
> = Flatten<Partial<Pick<T, K>> & Omit<T, K>>;
1
2
3
4
5
6

img

现在它就直观多了,那我们也就无需再进行实际验证了。

在这里你其实也可以使用 DeepPartial<Pick<T, K>>,来把这些属性标记为深层的可选状态。

我们来实现其它类型的部分修饰:

export type MarkPropsAsRequired<
  T extends object,
  K extends keyof T = keyof T
> = Flatten<Omit<T, K> & Required<Pick<T, K>>>;

export type MarkPropsAsReadonly<
  T extends object,
  K extends keyof T = keyof T
> = Flatten<Omit<T, K> & Readonly<Pick<T, K>>>;

export type MarkPropsAsMutable<
  T extends object,
  K extends keyof T = keyof T
> = Flatten<Omit<T, K> & Mutable<Pick<T, K>>>;

export type MarkPropsAsNullable<
  T extends object,
  K extends keyof T = keyof T
> = Flatten<Omit<T, K> & Nullable<Pick<T, K>>>;

export type MarkPropsAsNonNullable<
  T extends object,
  K extends keyof T = keyof T
> = Flatten<Omit<T, K> & NonNullable<Pick<T, K>>>;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

而对于按照值类型的部分修饰,比如标记所有函数类型属性为可选,其实和这里是一样的思路:拆分-处理-组合,只不过我们此前使用基于键名裁剪的 Pick、Omit,现在我们需要基于键值类型裁剪的 PickByValueType、OmitByValueType 了。而在接下来的结构工具类型进阶中,我们会了解到如何基于键值类型去裁剪结构

这一节介绍的属性修饰工具类型在日常开发中也是非常常用的,如一个结构,在被用作多个 React 组件的属性类型时,可能存在一些属性修饰的差异。此时就可以基于这些工具类型,基于源头的接口结构做定制处理,避免多次声明基本重复的类型结构。

# 结构工具类型进阶

前面对结构工具类型主要给出了两个进阶方向:

  • 基于键值类型的 Pick 与 Omit;
  • 子结构的互斥处理。

首先是基于键值类型的 Pick 与 Omit,我们就称之为 PickByValueType 好了。它的实现方式其实还是类似部分属性修饰中那样,将对象拆分为两个部分,处理完毕再组装。只不过,现在我们无法预先确定要拆分的属性了,而是需要基于期望的类型去拿到所有此类型的属性名,如想 Pick 出所有函数类型的值,那就要先拿到所有的函数类型属性名。先来一个 FunctionKeys 工具类型:

type FuncStruct = (...args: any[]) => any;

type FunctionKeys<T extends object> = {
  [K in keyof T]: T[K] extends FuncStruct ? K : never;
}[keyof T];
1
2
3
4
5

{}[keyof T] 这个写法我们是第一次见,但我们可以拆开来看,先看看前面的 { [K in keyof T]: T[K] extends FuncStruct ? K : never; } 部分,为何在条件类型成立时它返回了键名 K,而非索引类型查询 T[K]

type Tmp<T extends object> = {
  [K in keyof T]: T[K] extends FuncStruct ? K : never;
};

type Res = Tmp<{
  foo: () => void;
  bar: () => number;
  baz: number;
}>;

type ResEqual = {
  foo: 'foo';
  bar: 'bar';
  baz: never;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

在 Res(等价于 ResEqual)中,我们获得了一个属性名-属性名字面量类型的结构,对于非函数类型的属性,其值为 never。然后,我们加上 [keyof T] 这一索引类型查询 + keyof 操作符的组合:

type WhatWillWeGet = Res[keyof Res]; // "foo" | "bar"
1

我们神奇地获得了所有函数类型的属性名!这又是如何实现的呢?其实就是我们此前学习过的,当索引类型查询中使用了一个联合类型时,它会使用类似分布式条件类型的方式,将这个联合类型的成员依次进行访问,然后再最终组合起来,上面的例子可以这么简化:

type WhatWillWeGetEqual1 = Res["foo" | "bar" | "baz"];
type WhatWillWeGetEqual2 = Res["foo"] | Res["bar"] | Res["baz"];
type WhatWillWeGetEqual3 = "foo" | "bar" | never;
1
2
3

通过这一方式,我们就能够获取到符合预期类型的属性名了。如果希望抽象“基于键值类型查找属性”名这么个逻辑,我们就需要对 FunctionKeys 的逻辑进行封装,即将预期类型也作为泛型参数,由外部传入:

type ExpectedPropKeys<T extends object, ValueType> = {
  [Key in keyof T]-?: T[Key] extends ValueType ? Key : never;
}[keyof T];

type FunctionKeys<T extends object> = ExpectedPropKeys<T, FuncStruct>;

expectType<
  FunctionKeys<{
    foo: () => void;
    bar: () => number;
    baz: number;
  }>
>('foo');

expectType<
  FunctionKeys<{
    foo: () => void;
    bar: () => number;
    baz: number;
  }>
  // 报错,因为 baz 不是函数类型属性
>('baz');
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

注意,为了避免可选属性对条件类型语句造成干扰,这里我们使用 -? 移除了所有可选标记。

既然我们现在可以拿到对应类型的属性名,那么把这些属性交给 Pick,不就可以得到由这些属性组成的子结构了?

export type PickByValueType<T extends object, ValueType> = Pick<
  T,
  ExpectedPropKeys<T, ValueType>
>;

expectType<PickByValueType<{ foo: string; bar: number }, string>>({
  foo: 'linbudu',
});

expectType<
  PickByValueType<{ foo: string; bar: number; baz: boolean }, string | number>
>({
  foo: 'linbudu',
  bar: 599,
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

OmitByValueType 也是类似的,我们只需要一个和 ExpectedPropKeys 作用相反的工具类型即可,比如来个 FilteredPropKeys,只需要调换条件类型语句结果的两端即可:

type FilteredPropKeys<T extends object, ValueType> = {
  [Key in keyof T]-?: T[Key] extends ValueType ? never : Key;
}[keyof T];

export type OmitByValueType<T extends object, ValueType> = Pick<
  T,
  FilteredPropKeys<T, ValueType>
>;

expectType<OmitByValueType<{ foo: string; bar: number }, string>>({
  bar: 599,
});

expectType<
  OmitByValueType<{ foo: string; bar: number; baz: boolean }, string | number>
>({
  baz: true,
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

或者,如果你想把 ExpectedPropKeys 和 FilteredPropKeys 合并在一起,其实也很简单,只是需要引入第三个泛型参数来控制返回结果:

type Conditional<Value, Condition, Resolved, Rejected> = Value extends Condition
  ? Resolved
  : Rejected;

export type ValueTypeFilter<
  T extends object,
  ValueType,
  Positive extends boolean
> = {
  [Key in keyof T]-?: T[Key] extends ValueType
    ? Conditional<Positive, true, Key, never>
    : Conditional<Positive, true, never, Key>;
}[keyof T];

export type PickByValueType<T extends object, ValueType> = Pick<
  T,
  ValueTypeFilter<T, ValueType, true>
>;

export type OmitByValueType<T extends object, ValueType> = Pick<
  T,
  ValueTypeFilter<T, ValueType, false>
>;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

看起来好像很完美,但这里基于条件类型的比较是否让你想到了某个特殊情况?即在联合类型的情况下,1 | 2 extends 1 | 2 | 3(通过泛型参数传入) 会被视为是合法的,这是由于分布式条件类型的存在。而有时我们希望对联合类型的比较是全等的比较,还记得我们说怎么禁用分布式条件类型吗?让它不满足裸类型参数这一条即可:

type Wrapped<T> = [T] extends [boolean] ? "Y" : "N";
1

在这里我们也只需要简单进行改动即可:

type StrictConditional<Value, Condition, Resolved, Rejected> = [Value] extends [
  Condition
]
  ? Resolved
  : Rejected;
1
2
3
4
5

看起来好像没问题,但这里其实不够完美!比如下面这种情况:

type Res1 = StrictConditional<1 | 2, 1 | 2 | 3, true, false>; // true
1

当条件不再是一个简单的单体类型,而是一个联合类型时,我们使用数组的方式就产生问题了。因为 Array<1 | 2> extends Array<1 | 2 | 3> 就是合法的,第一个数组中的可能元素类型均被第二个数组的元素类型包含了,无论如何都是其子类型

那么现在应该怎么办?其实只要反过来看,既然 Array<1 | 2> extends Array<1 | 2 | 3> 成立,那么 Array<1 | 2 | 3> extends Array<1 | 2> 肯定是不成立的,我们只要再加一个反方向的比较即可:

type StrictConditional<A, B, Resolved, Rejected, Fallback = never> = [
  A
] extends [B]
  ? [B] extends [A]
    ? Resolved
    : Rejected
  : Fallback;
1
2
3
4
5
6
7

在这种情况下 Value 和 Condition 的界限就比较模糊了,我们只是在比较两个类型是否严格相等,并没有值和表达式的概念了,因此就使用 A、B 来简称。

此时结果就符合预期了,需要联合类型完全一致:

type Res1 = StrictConditional<1 | 2, 1 | 2 | 3, true, false>; // false
type Res2 = StrictConditional<1 | 2 | 3, 1 | 2, true, false, false>; // false
type Res3 = StrictConditional<1 | 2, 1 | 2, true, false>; // true
1
2
3

应用到 TypeFilter 中:

export type StrictValueTypeFilter<
  T extends object,
  ValueType,
  Positive extends boolean = true
> = {
  [Key in keyof T]-?: StrictConditional<
    ValueType,
    T[Key],
    // 为了避免嵌套太多工具类型,这里就不使用 Conditional 了
    Positive extends true ? Key : never,
    Positive extends true ? never : Key,
    Positive extends true ? never : Key
  >;
}[keyof T];

export type StrictPickByValueType<T extends object, ValueType> = Pick<
  T,
  StrictValueTypeFilter<T, ValueType>
>;

expectType<
  StrictPickByValueType<{ foo: 1; bar: 1 | 2; baz: 1 | 2 | 3 }, 1 | 2>
>({
  bar: 1,
});

export type StrictOmitByValueType<T extends object, ValueType> = Pick<
  T,
  StrictValueTypeFilter<T, ValueType, false>
>;

expectType<
  StrictOmitByValueType<{ foo: 1; bar: 1 | 2; baz: 1 | 2 | 3 }, 1 | 2>
>({
  foo: 1,
  baz: 3,
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37

需要注意的是,由于 StrictOmitByValueType 需要的是不符合类型的属性,因此这里 StrictConditional 的 Fallback 泛型参数也需要传入 Key (即第五个参数中的 Positive extends true ? never : Key),同时整体应当基于 Pick 来实现。

对于基于属性类型的结构工具类型就到这里,这一部分可能需要你先稍微放慢速度,好好理解一番。因为并不完全是我们此前了解到的知识,比如分布式条件类型中,我们并没有说到条件为联合类型时可能出现的问题。这是因为脱离实际使用去讲,很难建立并加深你对这一场景的印象,但我想现在你已经深刻记住它了。

接下来是基于结构的互斥工具类型。想象这样一个场景,假设我们有一个用于描述用户信息的对象结构,除了共有的一些基础结构以外,VIP 用户和普通用户、游客这三种类型的用户各自拥有一些独特的字段,如 vipExpires 代表 VIP 过期时间,仅属于 VIP 用户,promotionUsed 代表已领取过体验券,属于普通用户,而 refererType 代表跳转来源,属于游客。

先来看看如何声明一个接口,它要么拥有 vipExpires,要么拥有 promotionUsed 字段,而不能同时拥有这两个字段。你可能会首先想到使用联合类型?

interface VIP {
  vipExpires: number;
}

interface CommonUser {
  promotionUsed: boolean;
}

type User = VIP | CommonUser;
1
2
3
4
5
6
7
8
9

很遗憾,这种方式并不会约束“不能同时拥有”这个条件:

const user1: User = {
  vipExpires: 599,
  promotionUsed: false,
};
1
2
3
4

为了表示不能同时拥有,实际上我们应该使用 never 类型来标记一个属性。这里我们直接看完整的实现:

export type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never };

export type XOR<T, U> = (Without<T, U> & U) | (Without<U, T> & T);

type XORUser = XOR<VIP, CommonUser>;


expectType<XORUser>({
  vipExpires: 0,
});

expectType<XORUser>({
  promotionUsed: false,
});

// 报错,至少需要一个
// @ts-expect-error
expectType<XORUser>({
});

// 报错,不允许同时拥有
// @ts-expect-error
expectType<XORUser>({
  promotionUsed: false,
  vipExpires: 0,
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

对 Without 做进一步展开可以看到,它其实就是将声明了一个不变的原属性+为 never 的其他属性的接口:

// {
//    vipExpires?: never;
// }
type Tmp1 = Flatten<Without<VIP, CommonUser>>;
// {
//    vipExpires?: never;
//    promotionUsed: boolean;
// }
type Tmp2 = Flatten<Tmp1 & CommonUser>;
1
2
3
4
5
6
7
8
9

再通过联合类型的合并,这样一来 XORUser 就满足了“至少实现 VIP / CommonUser 这两个接口中的一个”,“不能同时实现 VIP / CommonUser ”这两个条件。如果加上游客类型实现三个互斥属性,也只需要额外嵌套一层:

interface Visitor {
  refererType: RefererType;
}

// 联合类型会自动合并重复的部分
type XORUser = XOR<VIP, XOR<CommonUser, Visitor>>;
1
2
3
4
5
6

我们还可以使用互斥类型实现绑定效果,即要么同时拥有 A、B 属性,要么一个属性都没有:

type XORStruct = XOR<
  {},
  {
    foo: string;
    bar: number;
  }
>;

// 没有 foo、bar
expectType<XORStruct>({});

// 同时拥有 foo、bar
expectType<XORStruct>({
  foo: 'linbudu',
  bar: 599,
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

互斥工具类型在很多实战场景下都有重要意义,它在联合类型的基础上添加了属性间的互斥逻辑,现在你可以让你的接口结构更加精确了!

# 集合工具类型进阶

在集合工具类型中我们给到的进阶方向,其实就是从一维原始类型集合,扩展二维的对象类型,在对象类型之间进行交并补差集的运算,以及对同名属性的各种处理情况。

对于对象类型的交并补差集,我们仍然沿用“降级”的处理思路,把它简化为可以用基础工具类型处理的问题即可。在这里,对象类型的交并补差集基本上可以降维到对象属性名集合的交并补差集问题,比如交集就是两个对象属性名的交集,使用属性名的交集访问其中一个对象,就可以获得对象之间的交集结构(不考虑同名属性冲突下)。

复习一下前面的一维集合:

// 并集
export type Concurrence<A, B> = A | B;

// 交集
export type Intersection<A, B> = A extends B ? A : never;

// 差集
export type Difference<A, B> = A extends B ? never : A;

// 补集
export type Complement<A, B extends A> = Difference<A, B>;
1
2
3
4
5
6
7
8
9
10
11

img

我们对应地实现对象属性名的版本:

// 使用更精确的对象类型描述结构
export type PlainObjectType = Record<string, any>;

// 属性名并集
export type ObjectKeysConcurrence<
  T extends PlainObjectType,
  U extends PlainObjectType
> = keyof T | keyof U;

// 属性名交集
export type ObjectKeysIntersection<
  T extends PlainObjectType,
  U extends PlainObjectType
> = Intersection<keyof T, keyof U>;

// 属性名差集
export type ObjectKeysDifference<
  T extends PlainObjectType,
  U extends PlainObjectType
> = Difference<keyof T, keyof U>;

// 属性名补集
export type ObjectKeysComplement<
  T extends U,
  U extends PlainObjectType
> = Complement<keyof T, keyof U>;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

对于交集、补集、差集,我们可以直接使用属性名的集合来实现对象层面的版本:

export type ObjectIntersection<
  T extends PlainObjectType,
  U extends PlainObjectType
> = Pick<T, ObjectKeysIntersection<T, U>>;

export type ObjectDifference<
  T extends PlainObjectType,
  U extends PlainObjectType
> = Pick<T, ObjectKeysDifference<T, U>>;

export type ObjectComplement<T extends U, U extends PlainObjectType> = Pick<
  T,
  ObjectKeysComplement<T, U>
>;
1
2
3
4
5
6
7
8
9
10
11
12
13
14

需要注意的是在 ObjectKeysComplement 与 ObjectComplement 中,T extends U 意味着 T 是 U 的子类型,但在属性组成的集合类型中却相反,U 的属性联合类型是 T 的属性联合类型的子类型,因为既然 T 是 U 的子类型,那很显然 T 所拥有的的属性会更多嘛。

而对于并集,就不能简单使用属性名并集版本了,因为使用联合类型实现,我们并不能控制同名属性的优先级,比如我到底是保持原对象属性类型呢,还是使用新对象属性类型?

还记得我们在 MarkPropsAsOptional、PickByValueType 中使用的方式吗?将一个对象拆分成数个子结构,处理各个子结构,再将它们合并。那么对于合并两个对象的情况,其实就是两个对象各自特有的部分加上同名属性组成的部分。

对于 T、U 两个对象,假设以 U 的同名属性类型优先,思路会是这样的:

  • T 比 U 多的部分:T 相对于 U 的差集,ObjectDifference<T, U>
  • U 比 T 多的部分:U 相对于 T 的差集,ObjectDifference<U, T>
  • T 与 U 的交集,由于 U 的优先级更高,在交集处理中将 U 作为原集合, T 作为后传入的集合,ObjectIntersection<U, T>

我们就得到了 Merge:

type Merge<
  T extends PlainObjectType,
  U extends PlainObjectType
  // T 比 U 多的部分,加上 T 与 U 交集的部分(类型不同则以 U 优先级更高,再加上 U 比 T 多的部分即可
> = ObjectDifference<T, U> & ObjectIntersection<U, T> & ObjectDifference<U, T>;
1
2
3
4
5

如果要保证原对象优先级更高,那么只需要在交集处理中将 T 视为原集合,U 作为后传入的集合:

type Assign<
  T extends PlainObjectType,
  U extends PlainObjectType
  // T 比 U 多的部分,加上 T 与 U 交集的部分(类型不同则以 T 优先级更高,再加上 U 比 T 多的部分即可
> = ObjectDifference<T, U> & ObjectIntersection<T, U> & ObjectDifference<U, T>;
1
2
3
4
5

除了简单粗暴地完全合并以外,我们还可以实现不完全的并集,即使用对象 U 的属性类型覆盖对象 T 中的同名属性类型,但不会将 U 独特的部分合并过来:

type Override<
  T extends PlainObjectType,
  U extends PlainObjectType
  // T 比 U 多的部分,加上 T 与 U 交集的部分(类型不同则以 U 优先级更高(逆并集))
> = ObjectDifference<T, U> & ObjectIntersection<U, T>;
1
2
3
4
5

这样,我们完成了从一维集合到二维集合的跨越。你也可以探索更多样的情况,比如两个对象各自独有部分组成的新集合(即从并集中剔除掉交集)就是一个很适合自己动手巩固印象的好例子。

# 模式匹配工具类型进阶

在内置工具类型一节中,我们对模式匹配工具类型的进阶方向其实只有深层嵌套这么一种,特殊位置的 infer 处理其实大部分时候也是通过深层嵌套实现,比如此前我们实现了提取函数的首个参数类型:

type FirstParameter<T extends FunctionType> = T extends (
  arg: infer P,
  ...args: any
) => any
  ? P
  : never;
1
2
3
4
5
6

要提取最后一个参数类型则可以这样:

type FunctionType = (...args: any) => any;

type LastParameter<T extends FunctionType> = T extends (arg: infer P) => any
  ? P
  : T extends (...args: infer R) => any
  ? R extends [...any, infer Q]
    ? Q
    : never
  : never;

type FuncFoo = (arg: number) => void;
type FuncBar = (...args: string[]) => void;
type FuncBaz = (arg1: string, arg2: boolean) => void;

type FooLastParameter = LastParameter<FuncFoo>; // number
type BarLastParameter = LastParameter<FuncBar>; // string
type BazLastParameter = LastParameter<FuncBaz>; // boolean
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

这也是模式匹配中常用的一种方法,通过 infer 提取到某一个结构,然后再对这个结构进行 infer 提取。

我们在此前曾经讲到一个提取 Promise 内部值类型的工具类型 PromiseValue, TypeScript 内置工具类型中也存在这么一个作用的工具类型,并且它的实现要更为严谨:

type Awaited<T> = T extends null | undefined
  ? T 
  : T extends object & { then(onfulfilled: infer F): any }
  ? F extends (value: infer V, ...args: any) => any 
    ? Awaited<V>
    : never
  : T;
1
2
3
4
5
6
7

首先你会发现,在这里 Awaited 并非通过 Promise<infer V> 来提取函数类型,而是通过 Promise.then 方法提取,首先提取到 then 方法中的函数类型,再通过这个函数类型的首个参数来提取出实际的值。

更严谨地来说,PromiseValue 和 Awaited 并不应该放在一起比较,前者就只想提取 Promise<void> 这样结构的内部类型,后者则像在类型的层面执行了 await Promise.then() 之后的返回值类型。同样的,这里也用到了 infer 伴随结构转化的例子。

对于内置模式匹配工具类型的进阶我们暂时只进行到这里,在后续的漫谈篇中,我们会不再拘束于“内置”,而是会更新更多复杂的模式匹配工具类型。

# 总结与预告

这一节我们了解了属性修饰、结构、集合、模式匹配这四大类的工具类型进阶,也通过这些进阶类型了解到了常用的类型编程方式,如对一个对象结构拆分为多个子结构再分别处理,将复杂类型降维到基础类型再逐个击破,以及在嵌套的条件类型中基于 infer 多次修改类型结构来提取最终需要的类型。最重要的是,这些思路不仅仅会用在这一节的工具类型实现里,当你以后面对更复杂的场景需要从头写一个工具类型时,也完全可以使用,不会再无从下手了。

至此,我们就完成了对 TypeScript 基本类型能力的学习。一路走来甚是不易,我们用了 16 节,总计约 7w 字的内容,来完成对 TypeScript 核心类型能力的入门、进阶、归纳与实战。从基本的类型标注到内置类型的使用,从掌握类型工具到类型系统的深入探索,从工具类型入门到进阶再到整理出类型编程的 4 大范式(访问性修饰、结构、集合以及模式匹配)。

对于类型编程部分,我想带给你的最重要收获其实就是,你不会再畏惧眼花缭乱的类型编程了。正如始终贯穿这几节的核心理念,无论多复杂的类型编程,最终都可以拆分为数个基础的工具类型来实现,你需要锻炼的就是拆分的思路。

下一节我们还要继续接触类型。先别激动,接下来的类型要更好玩有趣一些,它是 TypeScript 在 4.1 版本引入的重磅特性——模板字符串类型,我们会用两节的内容带你完成相关学习。

# 扩展阅读

# RequiredKeys、OptionalKeys

在属性修饰工具类型中我们只实现了 FunctionKeys,它的实现相对简单,因为只需要判断类型即可。那如果,我们要获取一个接口中所有可选或必选的属性呢?现在没法通过类型判断,要怎么去收集属性?

这一部分的实际意义不大,因此我特意放在扩展阅读里,下面的 MutableKeys、ImmutableKeys 也是如此。

首先是 RequiredKeys ,我们可以通过一个很巧妙的方式判断一个属性是否是必选的,先看一个例子:

type Tmp1 = {} extends { prop: number } ? "Y" : "N"; // "N"
type Tmp2 = {} extends { prop?: number } ? "Y" : "N"; // "Y"
1
2

在类型层级一节中我们已经了解,此时 TypeScript 会使用基于结构化类型的比较,也就意味着由于 { prop: number } 可以视为继承自 {}{} extends { prop: number } 是不满足条件的。但是,如果这里的 prop 是可选的,那就不一样了!由于 { prop?: number } 也可以是一个空的接口结构,那么 {} extends { prop?: number } 就可以认为是满足的。

因此,我们可以这么实现:

export type RequiredKeys<T> = {
  [K in keyof T]-?: {} extends Pick<T, K> ? never : K;
}[keyof T];
1
2
3

OptionalKeys 也是类似:

export type OptionalKeys<T> = {
  [K in keyof T]-?: {} extends Pick<T, K> ? K : never;
}[keyof T];
1
2
3

# MutableKeys、ImmutableKeys

MutableKeys 和 ImmutableKeys 则要更加复杂一些,因为 readonly 修饰符无法简单地通过结构化类型比较,我们需要一个能对只读这一特性进行判断的辅助工具类型,直接看例子再讲解:

type Equal<X, Y, A = X, B = never> = (<T>() => T extends X ? 1 : 2) extends <
  T
>() => T extends Y ? 1 : 2
  ? A
  : B;
1
2
3
4
5

在这里,<T>() => T extends X ? 1 : 2<T>() => T extends Y ? 1 : 2 这两个函数结构实际上起辅助作用,内部的条件类型并不会真的进行运算。我们实际上是借助这一辅助结构判断类型 X 与 Y 的全等性,这一全等性就包括了 readonly 修饰符与可选性等。

我们基于其实现 MutableKeys 和 ImmutableKeys:

export type MutableKeys<T extends object> = {
  [P in keyof T]-?: Equal<
    { [Q in P]: T[P] },
    { -readonly [Q in P]: T[P] },
    P,
    never
  >;
}[keyof T];

expectType<MutableKeys<{ a: string; readonly b: string }>>('a');
expectNotType<MutableKeys<{ a: string; readonly b: string }>>('b');

export type ImmutableKeys<T extends object> = {
  [P in keyof T]-?: Equal<
    { [Q in P]: T[P] },
    { -readonly [Q in P]: T[P] },
    never,
    P
  >;
}[keyof T];

expectType<ImmutableKeys<{ a: string; readonly b: string }>>('b');
expectNotType<ImmutableKeys<{ a: string; readonly b: string }>>('a');
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

在 MutableKeys 中,我们传入本次映射的单个属性组成的接口结构,以及这一结构去除了 readonly 的版本,如果前后两个接口结构被判定为全等,那就说明这一次映射的属性不是只读的。在 ImmutableKeys 中也是,但我们调换了符合条件类型时的正反结果位置。

Equal 这个工具类型在很多情况下还有特殊的妙用,不妨再试试各种类型都扔进来比一比?

Last Updated: 10/9/2023, 5:43:25 PM