6
头图
本文参与了SegmentFault 思否写作挑战赛活动,欢迎正在阅读的你也加入。

前言

事情是这样的,有这样一道 ts 类型题,代码如下所示:

type Union = "Mon" | "Tue" | "Wed";
// 补充这里的类型代码
type Mapping<T extends Union, Value extends string> = any;
type Res = Mapping<Union, "周一" | "周二" | "周三">;
// 以下是输出结果
// {
//     mon: "周一";
//     Tue: "周二";
//     Wed: "周三";
// }

观察题目,其实就是将两个联合类型的值组合成接口,其中第一个联合类型的值作为属性,第二个联合类型的值则作为属性值,并且两者的属性顺序是一一对应的。下面跟着我一起来分析,通过这道题,我们能理解到 ts 的不少知识点,不信继续往下看。

分析

实际上,在 ts 当中,想要保证 ts 的顺序是很困难的,这与 ts 编译器有关,不过这不影响我们对这道题的分析,那么这道题如何解决呢?思路就是想办法将 2 个联合类型构造成数组,然后就可以根据数组项一一对应来转成对象了,那么这道题的难点在于如何转成数组。

转成数组的前提就是我们将联合类型的每一项取出来然后添加到数组中,那么如何提取呢?下面让我们一步一步来实现。

将并集转成交集

联合类型我们也可以叫做并集,如: 1 | 2 | 3,而要实现添加的第一步,我们需要将并集转成交集,那么如何进行交集的转换呢?

其实我们可以将并集的每一项使用函数来推断,在这里我们需要理解 ts 中的 2 个关键字用法,如下:

  1. extends: 既可以表示类的继承,也可以表示条件判断(相当于 js 的全等)。
  2. infer: 该关键字用于推导某个类型。

根据以上分析,我们就可以实现并集转成交集,代码如下:

//  X | Y | Z ==> X & Y & Z
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
  k: infer I
) => void
  ? I
  : never;

以上类型就实现了并集对交集的转换,理解起来也很容易,就是判断给定的泛型参数是否是任意类型 any,如果是则构造成函数参数为该类型,然后使用 infer 关键字去推导参数类型,如果能推导出来,则返回推导出来的结果,否则返回 never。

any 与 never 与 void 类型

这个 ts 类型也涉及到了 3 个类型,即任意类型 any,从不类型 never,和 void 类型。

any 类型

其中 any 用来表示允许赋值为任意类型。在 ts 中,如果是一个普通类型,在赋值过程中改变类型是不被允许的。例如:

let a: string = "123";
a = 2; // error TS2322: Type 'number' is not assignable to type 'string'

以上定义 a 变量的类型是 string,因此修改变量值为数值,则 ts 编译会出错,但如果是赋值为任意值类型,则以上操作不会报错,如下所示:

let a: any = "123";
a = 2; // 允许修改,因为是任意值类型

我们也可以访问任意类型的属性和方法,如下所示:

let b: any = "b";
console.log(b.name); // ts编译不会报错
console.log(b.setName("a")); // ts编译不会出错

也就是说,声明一个变量为任意值之后,对它的任何操作,返回的内容的类型都是任意值。

在 ts 中,一个未声明类型的变量,也会被推导成任意类型,如:

let a;
a = "a";
a = 2;
a.setName("b");
// 以上操作在ts中都不会报错

never 类型

never 类型表示从不存在的类型,比如一个函数抛出异常,它的返回类型就是 never,如:

const fn = (msg: string) => throw new Error(msg); // never

void 类型

void 类型表示没有返回值,通常用在没有任何返回值的函数中。如:

const fn = (): void => {
  alert(123);
};

以上类型是包装成函数类型推导,对于函数有没有返回值没有任何意义,因此这里只需要使用 void 来代表返回值即可。

将联合类型转换成重载函数类型

下一步,我们就需要将联合类型转换成重载函数类型,例如:

X | Y ==> ((x: X)=>void) & ((y:Y)=>void)

我们要如何实现呢?其实就是将泛型参数包装成函数类型,然后再调用用前面的并集转交集类型,如下:

type UnionToOvlds<U> = UnionToIntersection<
  U extends any ? (f: U) => void : never
>;

做这一步的目的是方便将联合类型中的每一项提取出来,因此需要这个类型,接下来我们就需要将联合类型的每一项取出来,我们叫做 PopUnion 类型。

从联合类型中取出每一个类型

有了前面 2 个类型的铺垫,取出联合类型中的每一个类型就很容易,我们只需要包装成重载函数类型,然后使用 infer 推断函数参数类型,返回参数类型即可。代码如下:

type PopUnion<U> = UnionToOvlds<U> extends (f: infer A) => void ? A : never;

能够取出联合类型的每一个类型,那么构造成数组就很容易了,不过接下来还需要一个类型,那就是判断是否是联合类型,为此我们需要先实现这个类型,即 IsUnion 类型。

判断是否是联合类型

判断是否是联合类型比较简单,就是将类型构造成一个数组,然后使用两个数组比较,不过我们需要比较的是原始泛型参数构造成数组和转成交集构造成数组是否相等,相等则返回 false,否则返回 true。代码如下:

type IsUnion<T> = [T] extends [UnionToIntersection<T>] ? false : true;

联合类型转数组

接下来就是联合类型转数组类型的实现,首先我们需要用到上一节提到的判断是否是联合类型,如果是联合类型,则使用 PopUnion 类型提取联合类型的每一项,注意这里是需要递归的提取剩余项的,直到不是剩余项不是联合类型为止,因此这里我们没提取一项,都需要使用 Exclude 类型将联合类型中提取的排除掉,这样就得到了剩余的联合类型,然后我们使用第二个参数来存储结果,如果不是联合类型就直接添加到数组中。代码如下所示:

type UnionToArray<T, A extends unknown[] = []> = IsUnion<T> extends true
  ? UnionToArray<Exclude<T, PopUnion<T>>, [PopUnion<T>, ...A]>
  : [T, ...A];

以上代码我们使用泛型 A 是一个未知类型的数组,并默认赋值为空数组来存储结果,相当于我们是从联合类型当中一项一项的提取出来然后添加到 A 结果数组中,最终返回的结果就是一个由联合类型每一项组成的数组。

Exclude 类型

其中 Exclude 类型是 ts 内置类型,不过要实现还是比较简单的,简单来说就是如果两个参数相等,则不返回类型,否则返回原类型。代码如下:

type Exclude<T, U> = T extends U ? never : T;

获取数组的长度

接下来我们还需要比较两个联合类型提取出来的数组长度是否相同,为此我们需要先实现如何获取一个数组类型的长度,观察发现数组是存在一个 length 属性的,因此我们可以判断如果存在 length 属性,并使用 infer 推断具体值,能够推断出来就返回这个推断的值,否则返回 never,代码如下:

type Length<T extends ReadonlyArray<any>> = T extends { length: infer L }
  ? L
  : never;

这个代码有一个类型即 ReadonlyArray 类型,它也是 ts 的一个内置类型,表示数组项只读的数组,那么这个类型是如何实现的呢?

ReadonlyArray 数组类型

这个类型的实现还是很简单的,就是只读数组只有一个 at 方法,数组 at 方法的作用就是获取一个整数值并返回该索引处的项目,允许参数是正整数和负整数,负整数从数组的最后一项开始倒数。因此我们需要先实现这个只有 at 方法的接口,代码如下:

interface RelativeIndexable<T> {
  at(index: number): T | undefined;
}

而只读数组类型 ReadonlyArray 只需要继承这个接口就行了,代码如下:

interface ReadonlyArray<T> extends RelativeIndexable<T> {}

实现比较两个数组长度的类型

有了能够获取数组长度的类型,接下来比较两个数组长度的类型就很简单了,代码如下:

type CompareLength<
  T extends ReadonlyArray<any>,
  U extends ReadonlyArray<any>
> = Length<T> extends Length<U> ? true : false;

简单来说,就是两个数组长度一样就返回 true,否则返回 false,这也限制了我们最终实现的类型 2 个参数的联合类型最终提取出来的元素一定要一样。

将属性构造成接口

接下来我们要实现将属性构造成接口,要想构造成接口,那就需要属性和属性值,因此这个类型的实现是有 2 个参数的,可以看到我们最终实现的 Mapping 就是有 2 个参数,第一个参数作为属性,第二个参数作为属性值。而由于接口属性类型有限制,即只能是 PropertyKey 类型,因此我们是需要判断的,同理,为了实现属性和属性值一一对应有值的情况下,我们也需要对第二个参数做判断,只有满足 2 个参数类型都是 PropertyKey 类型,才能构造成接口,并且构造成接口我们可以使用 Record 类型。

根据以上分析,我们的最终代码就实现如下:

// 不一定要叫Callback,也可以叫名字
type Callback<T, U> = T extends PropertyKey
  ? U extends PropertyKey
    ? Record<T, U>
    : never
  : never;

以上还涉及到了 ts 的两个内置类型,第一个是 PropertyKey 类型,第二个则是Record<T,U>类型,下面我们来一一看下这 2 个类型的实现。

PropertyKey 类型

第一个 PropertyKey 类型非常简单,它表示对象的属性类型,我们只需要知道 js 对象的属性只能是字符串或者数字或者符号就可以知道这个类型的实现,代码如下:

type PropertyKey = string | number | symbol;

可以看到这就是一个联合类型,属性的类型只能是字符串或者数值或者符号。

Record 类型

Record 类型表示构造一个构造一个具有类型 T 的一组属性 U 的类型,我们只需要使用 in 操作符即可实现,因为这个类型的第一个参数是要作为接口属性的,而第二个参数则是作为对应的属性值。代码如下:

type Record<T extends keyof any, U> = {
  [K in T]: U;
};

这就是 Record 类型的实现,这其中还设计到了 ts 的一个关键字,即 keyof,它表示提取类型的属性,这个关键字通常用来提取接口的属性,最后会返回组成属性的联合类型。例如:

type Test = {
  a: string;
  1: number;
};
type TestKey = keyof Test; // 'a' | 1

将两个数组类型构造成接口

有了前面的几个类型的实现,接下来我们需要实现一个根据 2 个参数数组构造成接口的类型,为此我们需要定义第三个参数,第三个参数应该是一个接口对象,用来当作最终返回的结果,默认是一个空对象,而前面 2 个参数就是我们的属性组成的只读数组。结构如下所示:

type Zip<
  T extends ReadonlyArray<any>,
  U extends ReadonlyArray<any>,
  R extends Record<string, any> = {}
> = any;

接下来第一步,首先我们需要比较 2 个参数数组长度应该是一样的,不一样,我们就直接返回 never。如下所示:

type Zip<
  T extends ReadonlyArray<any>,
  U extends ReadonlyArray<any>,
  R extends Record<string, any> = {}
> = CompareLength<T, U> extends true ? any : never;
ps: 以上包括后面用 any 表示我们还没有实现,起一个占位符作用,方便我们理解实现思路。

紧接着第二步,我们需要判断是否是空数组,只需要判断其中一个即可,因为我们已经判断了两个数组长度是否相等,如果其中一个是空数组,那么另一个必定也是空数组,如果是空数组,直接返回结果即可,此时默认值就是空对象,直接返回结果也合理,代码如下:

type Zip<
  T extends ReadonlyArray<any>,
  U extends ReadonlyArray<any>,
  R extends Record<string, any> = {}
> = CompareLength<T, U> extends true ? (T extends [] ? R : any) : never;

接下来第三步,我们还需要做判断,那就是如果 2 个数组都只有一个数组项,那么我们只需要将第一个数组项提取出来,这里当然是使用 infer 关键字来推导数组项,然后使用 Callback 类型构造成接口并与 R 结果取并集即可。代码如下所示:

type Zip<
  T extends ReadonlyArray<any>,
  U extends ReadonlyArray<any>,
  R extends Record<string, any> = {}
> = CompareLength<T, U> extends true
  ? T extends []
    ? R
    : T extends [infer F1]
    ? U extends [infer F2]
      ? R & Callback<F1, F2>
      : never
    : any
  : never;

第四步就是如果数组有多个数组项,则我们需要递归的取并集。代码如下所示:

type Zip<
  T extends ReadonlyArray<any>,
  U extends ReadonlyArray<any>,
  R extends Record<string, any> = {}
> = CompareLength<T, U> extends true
  ? T extends []
    ? R
    : T extends [infer F1]
    ? U extends [infer F2]
      ? R & Callback<F1, F2>
      : never
    : T extends [infer F1, ...infer T1]
    ? U extends [infer F2, ...infer T2]
      ? Zip<T1, T2, R & Callback<F1, F2>>
      : never
    : never
  : never;

虽然这个类型的实现代码比较长,但其实我们逐一拆分下来理解起来还是比较容易的。

实现 Mapping 类型

有了前面几个类型的实现,最终我们就可以解答这道题了,我们只需要将 2 个联合类型构造成 2 个数组,然后使用 Zip 类型将 2 个类型组成的数组转成接口即可,代码如下:

type Mapping<T extends Union, Value extends string> = Zip<
  UnionToArray<T>,
  UnionToArray<Value>
>;

以上代码很好理解,我们将 2 个联合类型使用 UnionToArray 构造成 2 个类型数组,然后使用 Zip 类型构造成接口。

优化

不过以上代码的实现还不算完美,因为我们最终的结果是使用 Record 类型展示的,并不直观,因此最后一步,我们还需要将 Record 类型转成可以直观看到的接口类型,很简单,只需要读取每一个接口属性即可,和 Record 类型实现原理很类似。代码如下:

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

最终实现版本

将优化后的代码与前面的实现合并,就得到了我们的最终实现,代码如下:

type Mapping<T extends Union, Value extends string> = ToObj<
  Zip<UnionToArray<T>, UnionToArray<Value>>
>;

下面,我们将以上所有实现代码整理到一起,代码如下:

// 第一步
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
  k: infer I
) => void
  ? I
  : never;
// 第二步
type UnionToOvlds<U> = UnionToIntersection<
  U extends any ? (f: U) => void : never
>;
// 第三步
type PopUnion<U> = UnionToOvlds<U> extends (f: infer A) => void ? A : never;
// 第四步
type IsUnion<T> = [T] extends [UnionToIntersection<T>] ? false : true;
// 第五步
type UnionToArray<T, A extends unknown[] = []> = IsUnion<T> extends true
  ? UnionToArray<Exclude<T, PopUnion<T>>, [PopUnion<T>, ...A]>
  : [T, ...A];
// 第六步
type Length<T extends ReadonlyArray<any>> = T extends { length: infer L }
  ? L
  : never;
// 第七步
type CompareLength<
  T extends ReadonlyArray<any>,
  U extends ReadonlyArray<any>
> = Length<T> extends Length<U> ? true : false;
// 第八步
type Callback<T, U> = T extends PropertyKey
  ? U extends PropertyKey
    ? Record<T, U>
    : never
  : never;
// 第九步
type Zip<
  T extends ReadonlyArray<any>,
  U extends ReadonlyArray<any>,
  R extends Record<string, any> = {}
> = CompareLength<T, U> extends true
  ? T extends []
    ? R
    : T extends [infer F1]
    ? U extends [infer F2]
      ? R & Callback<F1, F2>
      : never
    : T extends [infer F1, ...infer T1]
    ? U extends [infer F2, ...infer T2]
      ? Zip<T1, T2, R & Callback<F1, F2>>
      : never
    : never
  : never;
// 第十步
type ToObj<T> = {
  [K in keyof T]: T[K];
};
// 最终
type Mapping<T extends Union, Value extends string> = ToObj<
  Zip<UnionToArray<T>, UnionToArray<Value>>
>;

总结

下面我们来总结一下这道题中我们学到的知识点:

  1. extends 关键字用于条件判断。
  2. infer 关键字用于推导类型。
  3. keyof 关键字用于获取对象接口属性。
  4. ts 类型递归。
  5. ts 中的 3 个基本类型的含义,即 any,never,void 的含义。
  6. ts 中内置类型的实现,如: ReadonlyArray,Exclude,PropertyKey,Record。

以上的知识点,在 ts 类型体操当中将会经常用到,所以需要理解深刻。

最后

只是一道题目,我们就学到了 ts 的很多类型体操的知识,ts 类型这么有趣,难道不是吗?


夕水
5.2k 声望5.7k 粉丝

问之以是非而观其志,穷之以辞辩而观其变,资之以计谋而观其识,告知以祸难而观其勇,醉之以酒而观其性,临之以利而观其廉,期之以事而观其信。