17-内置工具类型进阶:类型编程进阶
课程
1
开篇:用正确的方式学习 TypeScript
已学完
学习时长: 7分24秒
2
工欲善其事:打造最舒适的 TypeScript 开发环境
学习时长: 19分14秒
3
进入类型的世界:理解原始类型与对象类型
学习时长: 37分40秒
4
掌握字面量类型与枚举,让你的类型再精确一些
学习时长: 24分53秒
5
函数与 Class 中的类型:详解函数重载与面向对象
学习时长: 50分40秒
6
探秘内置类型:any、unknown、never 与类型断言
学习时长: 34分58秒
7
类型编程好帮手:TypeScript 类型工具(上)
学习时长: 33分34秒
8
类型编程好帮手:TypeScript 类型工具(下)
学习时长: 40分52秒
9
类型编程基石:TypeScript 中无处不在的泛型
学习时长: 43分46秒
10
结构化类型系统:类型兼容性判断的幕后
学习时长: 20分48秒
11
类型系统层级:从 Top Type 到 Bottom Type
学习时长: 45分16秒
12
类型里的逻辑运算:条件类型与 infer
学习时长: 52分44秒
13
内置工具类型基础:别再妖魔化工具类型了!
学习时长: 44分47秒
14
反方向类型推导:用好上下文相关类型
学习时长: 18分37秒
15
函数类型:协变与逆变的比较
学习时长: 21分38秒
16
了解类型编程与类型体操的意义,找到平衡点
学习时长: 4分27秒
17
内置工具类型进阶:类型编程进阶
学习时长: 83分3秒
18
基础类型新成员:模板字符串类型入门
学习时长: 32分58秒
19
类型编程新范式:模板字符串工具类型进阶
学习时长: 69分27秒
20
工程层面的类型能力:类型声明、类型指令与命名空间
学习时长: 53分23秒
21
在 React 中愉快地使用 TypeScript:内置类型与泛型坑位
学习时长: 78分9秒
22
让 ESLint 来约束你的 TypeScript 代码:配置与规则集介绍
学习时长: 57分13秒
23
全链路 TypeScript 工具库,找到适合你的工具
学习时长: 25分13秒
24
说说 TypeScript 和 ECMAScript 之间那些事儿
学习时长: 29分14秒
25
装饰器与反射元数据:了解装饰器基本原理与应用
学习时长: 81分53秒
26
控制反转与依赖注入:基于装饰器的依赖注入实现
学习时长: 69分6秒
27
TSConfig 全解(上):构建相关配置
学习时长: 55分33秒
28
TSConfig 全解(下):检查相关、工程相关配置
学习时长: 76分19秒
29
基于 Prisma + NestJs 的 Node API :前置知识储备
学习时长: 44分50秒
30
基于 Prisma + NestJs 的 Node API :项目开发与基于 Heroku 部署
学习时长: 46分40秒
31
玩转 TypeScript AST:AST Checker 与 CodeMod
学习时长: 85分18秒
32
感谢相伴:是结束,也是开始
学习时长: 3分21秒
33
漫谈篇:面试中的 TypeScript
学习时长: 4分25秒
juejin_logo copyCreated with Sketch.

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

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

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

本节代码见:Advanced Builtin Tool Types

属性修饰进阶

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

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

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

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

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

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

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

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

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,
    },
  },
});

在 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];
};

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

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

在对象结构中我们也常声明类型为 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]>;
};

当然,就像 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]>;
};

需要注意的是,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>;

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'
>;

啊哦,这可不好看出来具体效果。此时我们可以引入一个辅助的工具类型,我称其为 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>>;

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

在这里你其实也可以使用 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>>>;

而对于按照值类型的部分修饰,比如标记所有函数类型属性为可选,其实和这里是一样的思路:拆分-处理-组合,只不过我们此前使用基于键名裁剪的 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];

{}[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;
};

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

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

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

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

通过这一方式,我们就能够获取到符合预期类型的属性名了。如果希望抽象“基于键值类型查找属性”名这么个逻辑,我们就需要对 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');

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

既然我们现在可以拿到对应类型的属性名,那么把这些属性交给 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,
});

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,
});

或者,如果你想把 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 extends 1 | 2 | 3(通过泛型参数传入) 会被视为是合法的,这是由于分布式条件类型的存在。而有时我们希望对联合类型的比较是全等的比较,还记得我们说怎么禁用分布式条件类型吗?让它不满足裸类型参数这一条即可:

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

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

type StrictConditional<Value, Condition, Resolved, Rejected> = [Value] extends [
  Condition
]
  ? Resolved
  : Rejected;

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

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

当条件不再是一个简单的单体类型,而是一个联合类型时,我们使用数组的方式就产生问题了。因为 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;

在这种情况下 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

应用到 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,
});

需要注意的是,由于 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;

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

const user1: User = {
  vipExpires: 599,
  promotionUsed: false,
};

为了表示不能同时拥有,实际上我们应该使用 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,
});

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

// {
//    vipExpires?: never;
// }
type Tmp1 = Flatten<Without<VIP, CommonUser>>;
// {
//    vipExpires?: never;
//    promotionUsed: boolean;
// }
type Tmp2 = Flatten<Tmp1 & CommonUser>;

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

interface Visitor {
  refererType: RefererType;
}

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

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

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

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

// 同时拥有 foo、bar
expectType<XORStruct>({
  foo: 'linbudu',
  bar: 599,
});

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

集合工具类型进阶

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

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

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

// 并集
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>;

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

// 使用更精确的对象类型描述结构
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>;

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

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>
>;

需要注意的是在 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>;

如果要保证原对象优先级更高,那么只需要在交集处理中将 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>;

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

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

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

模式匹配工具类型进阶

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

type FirstParameter<T extends FunctionType> = T extends (
  arg: infer P,
  ...args: any
) => any
  ? P
  : never;

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

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

这也是模式匹配中常用的一种方法,通过 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;

首先你会发现,在这里 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"

在类型层级一节中我们已经了解,此时 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];

OptionalKeys 也是类似:

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

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;

在这里,<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');

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

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

留言
Ctrl + Enter
全部评论(46)
丝绒拿铁有点甜的头像
删除
web前端
讲得太好了。请问,可以表明出处,摘录代码到自己的文章中吗?
点赞
2
删除
(作者)
可以的[害羞]
点赞
回复
删除
标在文章的最上面,让大家都来看看大佬写的文章。真的讲得很好。[强]
可以的[害羞]
点赞
回复
「骚年」的头像
删除
请问下,值为什么是undefined呢【type Tmp1 = Flatten<Without<VIP, CommonUser>>;】
image
点赞
2
删除
我也是,我也不懂
点赞
回复
删除
不妨把 Without 中的 ? 去掉再看看,type Test = undefined | never 看看结果是啥就知道了
我也是,我也不懂
点赞
回复
Harexs的头像
删除
前端
其它类型的部分修饰 这里 MarkPropsAsNullable 和 MarkPropsAsNonNullable 应该是用 DeepNullable 和 DeepNonNullable 吧
点赞
1
删除
(作者)
这里全用的是浅层,否则就该叫MarkPropsAsDeep 了
点赞
回复
黄渡理工校长的头像
删除
interface VIP {
vipExpires: number;
}
interface CommonUser {
promotionUsed: boolean;
}
type User = VIP | CommonUser;

// 这个为什么成立
const user1: User = {
vipExpires: 599,
promotionUsed: false,
};
收起
点赞
1
删除
{vipExpires: 599, promotionUsed: false} 可以看成是 VIP 或 CommonUser 的子类型
点赞
回复
很帅很愁人的头像
删除
全村工程师 @ 下王庄村委员会
看不懂,先标记下吧
点赞
回复
y494890512的头像
删除
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]; 应该把readonly 前面的减号去掉吧.
收起
点赞
1
删除
作者之前说过,为了防止可选属性影响结果
点赞
回复
SPA枸杞泡脚盆的头像
删除
type Equal<X, Y, A = X, B = never>
A的默认值不对吧,调用时不传,A会变成对象而不是key了
点赞
2
删除
(作者)
这个工具类型和对象/key的比较没有关系,不要和下面的使用方式混淆了哈,它就是一个用来辅助判断键名修饰一致性的工具类型,在这里的实际意义就是下面的全等返回此时的 key P,否则返回 never,也不会出现不传而走了默认值的情况。类似另一条评论,泛型参数的默认值很多时候作用不大,尤其是 Equal 这种你无法确定实际应用场景的工具类型
1
回复
删除
ok的[赞]
这个工具类型和对象/key的比较没有关系,不要和下面的使用方式混淆了哈,它就是一个用来辅助判断键名修饰一致性的工具类型,在这里的实际意义就是下面的全等返回此时的 key P,否则返回 never,也不会出现不传而走了默认值的情况。类似另一条评论,泛型参数的默认值很多时候作用不大,尤其是 Equal 这种你无法确定实际应用场景的工具类型
点赞
回复
徐妞妞的头像
删除
type InterSection<A,B> = A extends B ? A: never
type A2 = {a: string; }
type A1 = A2 &{b: string; }
const a1: InterSection<A1,A2> = {a: '1111', b: '1'}
以这个为例子,怎么也看不出来是交集呀
点赞
1
删除
(作者)
这个是一维集合,不能使用对象类型的,对象类型的集合处理在下面
点赞
回复
徐妞妞的头像
删除
差集那块儿是不是描述的不太对呀
点赞
回复
小路就是我的头像
删除
不会开发工程师 @ 韩创科技
真是玩出花了
点赞
回复
西芹术士的头像
删除
GIS工程师 @ 福州
这一章学习路线太陡峭了,感觉拆成3章会好很多
1
回复
前端摸鱼儿的头像
删除
“因为既然 T 是 U 的子类型,那很显然 T 所拥有的的属性会更多嘛。 “
不是更少吗??
点赞
2
删除
(作者)
子类型意味着继承,继承之后是会添加属性的
点赞
回复
删除
原来如此 感谢
子类型意味着继承,继承之后是会添加属性的
点赞
回复
BULLsBLEED的头像
删除
请问这一章节内容在实际开发中 使用比例能有多少
点赞
2
删除
(作者)
如果团队严格要求类型覆盖率,以及业务场景足够复杂,那基本上都是能用上的,这一部分也是使用成本最低的类型编程
1
回复
删除
感谢分享,受益匪浅[愉快],已经二刷。之前实际项目只是简单接口和联合类型实现。很少使用类型工具层面,对ts有了新的认识
如果团队严格要求类型覆盖率,以及业务场景足够复杂,那基本上都是能用上的,这一部分也是使用成本最低的类型编程
点赞
回复
前端踩坑人员的头像
删除
难懂
点赞
回复
Kuroo的头像
删除
前端开发
请教一个问题:这里res3 为什么是Flatten<VIP> | Flatten<Visitor>
image
1
2
删除
(作者)
参考分布式条件类型一节
点赞
回复
删除
这里是和分布式条件类型一样的原理吗?那除了条件类型外,其他什么情况下才会有这种特性
点赞
回复
lorain的头像
删除
上面三种类型互斥type XORUser = XOR<VIP, XOR<CommonUser, Visitor>>;

是否可以使用如下类型, 便于更好理解?

type XORUserInfos<T extends Record<keyof any, unknown>, U extends keyof T = keyof T> = U extends U ?
({
[K in U]: T[K]
} & {
[K in Exclude<keyof T, U>]?: T[K]
})
: never
收起
image
点赞
回复
我睡前一定喝牛奶的头像
删除
社会闲散青年 @ 无业
U extends Partial<PlainObjectType>这句的Partial感觉在代码中没有意义吧...大佬,测试也没看到跟PlainObjectType有啥区别
点赞
1
删除
(作者)
是的是的,已更新
点赞
回复
安静的say的头像
删除
每天进步一点点的前端工程师
打卡,吸收了 7-8成,后面有需要在2刷
1
回复
怪气水的头像
删除
完全蒙
点赞
回复
0x效率工程师的头像
删除
点赞鼓励[送心]
点赞
回复
SHINE的头像
删除
前端开发工程师 @ 某个小公司
这一章也太难了
点赞
回复
我睡前一定喝牛奶的头像
删除
社会闲散青年 @ 无业
做个笔记,【<T>() => T extends X ? 1 : 2 和 <T>() => T extends Y ? 1 : 2 这两个函数结构实际上起辅助作用,内部的条件类型并不会真的进行运算。我们实际上是借助这一辅助结构判断类型 X 与 Y 的全等性,这一全等性就包括了 readonly 修饰符与可选性等】可以参考github.com,当函数T是未知时,依赖于延迟推断类型,延迟推断类型的可分配性依赖于内部isTypeIdenticalTo的检查,仅在两种情况下为真,1.extends分支结果是相同类型,2.两个条件类型是同一类型,即<T>() => T extends X ? 1 : 2 和 <T>() => T extends X时返回true,通过该方法判断类型是否是readonly,但更具体的理论就不清楚了....
收起
4
回复
zhedream的头像
删除
全栈工程师 @ @zhedream
mark
点赞
回复
linlin_的头像
删除
// 并集
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
删除
这里的补集不应该是export type Complement<A, B extends A> = Difference<B, A>; 吗
点赞
回复
SoleiQ的头像
删除
前端工程师
这一章太复杂了
点赞
3
删除
感觉加上泛型一抽象,就有点晕。但是仔细看一遍,捋清前后关系就好很多
点赞
回复
删除
没有之前函数的逆变什么的那章难
1
回复
删除
逆变与协变只是明白了几何概念,放在ts中一点没懂
没有之前函数的逆变什么的那章难
点赞
回复
The action has been successful