likes
comments
collection
share

玩转TS类型体操

作者站长头像
站长
· 阅读数 14

TS 因其强大的类型系统在前端领域可谓是大战拳脚。并且 TS 的类型系统是图灵完备的,这意味着它是可以用来进行逻辑运算,也就是我们戏称的类型体操😂。因此,我们通常可以在一些库中看到极其复杂的类型定义。

事情总有两面性,强大的类型系统带来的是复杂的语法,以及可能超过实现业务代码本身代码量的类型定义 ,导致上手成本稍高,毕竟any一把梭它不香吗,又不是不能用🤔。

本文就来解开类型体操的神秘面纱,逐步从最简单的高级类型使用到复杂的类型编程,带你玩转类型体操。

值空间和类型空间

在 TS 中,存在两个空间:值空间和类型空间。值空间用于存放会被编译成 JS 的实体内容,而类型空间用于存放各种类型信息,且这些信息会在编译后被完全擦除。两个空间彼此联系,但又互不影响。

以 class 为例:

class Person {
  name: string;
  constructor() {
    this.name = "张三";
  }
}

// p 的类型是 Person,这个类型来自类型空间
let p = new Person();  // 使用 new Person 时,此时的 Person 是一个值,来自值空间

像 class, enum 这样跨越了两个空间,但是在分别作为值或者类型使用时,又能正确的从不同的空间中获取信息,即使它们在两个空间的命名都是一样的。

而下文要做的类型编程都是在类型空间中进行的,语法上和常规值空间有所区别和限制,但是我们可以通过类比来理解它们。

基础类型编程

keyin

代码复用是程序设计中很重要的操作,那么类型如何复用呢?

type FriendList = {
  count: number;
  friends: {
    firstName: string;
    lastName: string;
  }[];
};

type Friends = FriendList["friends"];
// 等价于
// type Friends = {
//   firstName: string;
//   lastName: string;
// }[];

type Friend = Friends[number];
// 等价于
// type Friend = {
//   firstName: string;
//   lastName: string;
// };

keyin的方式和获取对象中属性的方式十分相似。并且 TS 只支持数组形式获取成员类型,不支持.语法。并且如果当前类型是数组或者元组,还可以通过[number]或者[1]进一步获取数组子项类型

keyof

keyof用于获取对象属性名,并用这些属性名构成联合类型:

type Friend = {
  firstName: string;
  lastName: string;
};

type Keys=keyof Friend; // "firstName" | "lastName"

如果keyof 操作的类型是类,则会获取该类所有的public属性名:

class Person {
  public name: string;
  protected age: number;
  private sex: boolean;

  public foo() {}
  protected bar() {}
  private baz() {}
}

type Keys=keyof Person; // "name" | "foo"

mapping 和 remapping

TS 还能对类型中每一项进行遍历和修改,比如内置的Readonly工具类型,可以将类型中每一项转换为readonly类型:

type Readonly<T> = {
    readonly [P in keyof T]: T[P];
};

type Friend = {
    firstName: string;
    lastName: string;
  };
  
type ReadonlyFriend=Readonly<Friend>;
// 等价于
// type ReadonlyFriend = {
//   readonly firstName: string;
//   readonly lastName: string;
// };

其中[P in keyof T]表明遍历 T 的每一项,我们可以将其类比为值空间的for...in...语法。

既然可以添加类型限制,那么自然也可以擦除类型限制,通过+/-操作符就可以做到这一点:

type RemoveReadonly<T> = {
  -readonly [P in keyof T]: T[P]; 
};
// Readonly 工具类型中到 readonly 等价于 +readonly,不过通常忽略不写,就像正数不写 + 前缀一样

type ReadonlyFriend = {
  readonly firstName: string;
  readonly lastName: string;
};

type Friend = RemoveReadonly<ReadonlyFriend>;
// 等价于
// type Friend = {
//     firstName: string;
//     lastName: string;
//   };

TS 在 4.1 后还增加了 remapping 的功能,具体来说是通过as关键字,将 Key 转换为另一种 Key:

type CapitalKey<T> = {
  [P in keyof T as `${Capitalize<P>}`]: T[P];
};

type Friend = {
  firstName: string;
  lastName: string;
};

type CFriend = CapitalKey<Friend>;
// 等价于
// type CFriend = {
//   FirstName: string;
//   LastName: string;
// };

其中Capitalize也是 TS 内置的 Intrinsic string types,搭配 remapping 特性使用非常合适。

extends、infer 强强联手

TS 中所有逻辑运算都依赖于extends,形如:T extends U ? X : Y,表示如果TU的子类,则返回类型为X,否则为Y。和三元表达式相似,搭配着泛型,就能实现一个类型工具函数:

type IsString<T> = T extends string ? true : false;

type A = IsString<string>; // true
type B = IsString<number>; // false

其中:

  1. IsString可以类比为值空间的函数,接受参数T
  2. IsString<string>就相当于是函数调用,此时的实参是string

extends还有一个很好用的特性,叫做 类型分发extends前面的参数如果为裸联合类型时则会分解联合类型进行判断(依次遍历所有的子类型进行条件判断)。然后将最终的结果组成新的联合类型

// Type 是裸类型,会进行分发
type NakedToArray<Type> = Type extends any ? Type[] : never;
type t1 = NakedToArray<string | number>; // string[] | number[];

// [Type] 不是裸类型,不会进行分发
type ToArray<Type> = [Type] extends [any] ? Type[] : never;
type t2 = ToArray<string | number>; // (string | number)[]

infer总是配合extends关键字一起使用的,我们将infer的行为称为模式匹配,再来看看内置的ReturnType工具类型:

type ReturnType<T extends (...args: any) => any> = T extends (
  ...args: any
) => infer R
  ? R
  : any;

type Foo = () => string;
type R = ReturnType<Foo>; // string

模式匹配简而言之是通过 extends 对类型参数做匹配,如果匹配成功,就会将匹配结果保存到通过 infer 声明的局部类型变量里。

举个例子,模式匹配就像是匹配一个罪犯的肖像画(T extends ( ...args: any ) => infer R),警察通过肖像画对嫌疑人(实参Foo)进行比对,如果刚好匹配,那么就会锁定到具体的罪犯(infer R),比如张三(string)。

模式匹配可以指定任意粒度的匹配:

// 数组 infer
type Infer1<T> = T extends (infer S)[] ? S : never;
type a1 = Infer1<string>; // never
type a2 = Infer1<string[]>; // string

// 单元素元组 infer
type Infer2<T> = T extends [infer S] ? S : never;
type b1 = Infer2<[string, number]>; // never
type b2 = Infer2<[string]>; // string

// 多元素元组 infer
// R 同样是一个元组类型,数量最少为 0 个
type Infer3<T> = T extends [infer S, ...infer R] ? [S, R] : never;
type c1 = Infer3<[]>; // never
type c2 = Infer3<[string]>; // [string, []]
type c3 = Infer3<[string, number]>; // [string, [number]]

// 字符串字面量 infer
type Infer4<T> = T extends `${infer S}` ? S : never;
type d1 = Infer4<"str">; // str
type d2 = Infer4<1>; // never

// 字符串字面量 infer
type Infer5<T> = T extends `${infer S}${infer R}` ? [S, R] : never;
type e1 = Infer5<"">; // never
type e2 = Infer5<"s">; // ["s", ""]
type e3 = Infer5<"st">; // ["s", "t"]

// 字符串字面量 infer
// 获取分隔符前后的字面量
type Infer6<T> = T extends `${infer S}__${infer R}` ? [S, R] : never;
type f1 = Infer6<"">; // never
type f2 = Infer6<"str1__str2">; // ["str1", "str2"]
type f3 = Infer6<"str1__str2__str3">; // ["str1", "str2__str3"]

// 其他类型 infer 
type Infer7<T> = T extends Promise<infer R> ? R : never;
type g1 = Infer7<"">; // never
type g2 = Infer7<Promise<number>>; // number

查看 在线 demo,更多粒度 infer 可自行尝试。

那么 TS 如何做循环呢?答案是递归。

比如将字符串字面量类型拆分为元组类型:

type StrToTuple<T extends string> = T extends `${infer F}${infer L}`
  ? [F, ...StrToTuple<L>]
  : [];

type t1=StrToTuple<"foo"> // ["f", "o", "o"]

查看 在线 demo

如同值空间的递归一样,如果递归的深度很深,就会溢出,TS 中递归的深度大概是 50 层,此时我们可借助尾递归优化,将深度增加到 1000 层(细节参见 官方文档):

type StrToTupleOptimize<T extends string,R extends string[]=[]> = T extends `${infer F}${infer L}`
  ? StrToTupleOptimize<L,[...R,F]>
  : R;

查看 在线 demo

never

never在类型编程中是一个十分有用的类型,它表示不存在的类型,所以在类型编程中任何包含never的类型都会将其中never忽略掉。我们来看看内置的工具类型Exclude:

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

type t1 = "a" | "b" | "c" | "d";
type t2 = "a" | "b";

// 按照分发特性,t3 应该是 never | never | "c" | "d",而 never 会被省略,所以最终结果是 "c" | "d"
type t3 = Exclude<t1, t2>; // "c" | "d"

挑战

实现两个数的加法

type Add<T1 extends number,T2 extends number>=??

type Five=Add<3,2>; // 5

类型系统并不能直接实现加法,所以显然不能type Add<T1 extends number,T2 extends number> = T1 + T2

这里需要使用元组的特性:元组是包含了固定长度的数组,所以 TS 能确切的知道元组的长度 ,即:

type Tuple1=[1,2,3];
type LengthOfTuple1=Tuple1["length"]; // 3

那么加法运算的思路就是:

  1. 分别构建长度为T1T2的元组

  2. 将两个元组合并,合并后元组的长度就是加法运算的结果

// 构建长度为 T 的元组
type GetTuple<T extends number, R extends any[] = []> = R["length"] extends T
  ? R
  : GetTuple<T, [...R, any]>;

type Add<T1 extends number, T2 extends number> = [
  ...GetTuple<T1>,
  ...GetTuple<T2>
]["length"];

type Five = Add<3, 2>; // 5

查看 在线 demo

元组可以获取长度信息这一特性在类型编程中是非常常用的手段,也是很多高级运算的基础。

实现 curry 定义

接下来来点难度,实现一个柯里化函数的类型定义。柯里化是函数式编程中一个重要的概念,通过柯里化可以将普通函数转换为可多次调用的函数链,实现参数的延迟绑定:

const add = (x: number, y: number, z: number) => x + y + z;

// 假设已经存在 curry 函数,这里我们不关系它的实现细节
const curriedAdd = curry(add);
// 以下操作都是合法的
const result1 = curriedAdd(1, 2, 3);
const result2 = curriedAdd(1)(2)(3);
const result3 = curriedAdd(1)(2, 3);
const result4 = curriedAdd()()(1)(2, 3);

可以看出柯里化的特点是 :

  1. 柯里化后的函数可以接受任意长度的参数,并且参数的类型要符合原始函数的参数类型
  2. 函数接受到的所有参数数量小于原始函数参数时,返回一个新的函数继续收集剩余参数
  3. 一旦函数接受到的所有参数数量和原始函数参数一致时,进行原始函数的运算,返回结果

要实现 curry,我们首先需要知道传入的函数的参数和返回值:

// 通过泛型函数,获取传入的函数的参数列表 P,和返回值 R
declare function curry<P extends any[], R>(
  fn: (...args: P) => R
): Curried<P, R>;

type Curried<P, R> = [P, R];

const add = (x: number, y: number, z: number) => x + y + z;

curry(add) // 此时 P 为 [number, number, number],R 为 number

根据上面几条特点,我们先来实现一个工具函数:

// 获取参数列表的长度
type Length<T extends any[]> = T["length"];

// 获取元组除了第一项外的剩余项
type Tail<T extends any[]> = T extends [infer F, ...infer L] ? L : [];

// 减去元组前 n 项元素
// R 是用于计数的变量,可以看到这里其实就用到了 tuple 数量是确定的的特性
type Drop<
  T extends any[],
  N extends number,
  R extends any[] = []
> = T extends [] ? [] : Length<R> extends N ? T : Drop<Tail<T>, N, [...R, any]>;

type d1 = Drop<[1, 2, 3, 4], 1>; // [2, 3, 4]
type d2 = Drop<[1, 2, 3, 4], 3>; // [4]
type d3 = Drop<[1, 2, 3, 4], 5>; // []
type d4 = Drop<[1, 2, 3, number[]], 4>; // []

// 用于获取 tuple 所有可能的组合
type PartialTuple<T extends any[]> = T extends [infer F, ...infer L]
  ? [] | [F] | [F, ...PartialTuple<L>]
  : T;

type p1 = PartialTuple<[1, 2, 3]>; // [1, 2, 3] | [1, 2] | [1] | []

接下来实现Curried的主体:

type Curried<P extends any[], R> = <T extends PartialTuple<P>>(
  ...args: T
) => Length<T> extends Length<P> ? R : Curried<Drop<P, Length<T>>, R>;

查看 在线 demo

但是如果存在 rest 参数时,上面的写法还有点点问题:

const add = (x: number, y: number, ...z: number[]) =>
  x + y + z.reduce((sum, i) => sum + i);

// 主要原因是下面的 l 的结果是 number,所以在判断已有参数是否满足原始函数的参数个数时就有问题了
type l=Length<[number,number,...number[]]> 

此时的判断方式就需要改变一下:

type Curried<P extends any[], R> = <T extends PartialTuple<P>>(
  ...args: T
) => Drop<P,Length<T>> extends [] ? R : Curried<Drop<P, Length<T>>, R>;
// 通过从 P 中 drop 掉 T 长度的数量后剩余的元组是不是一个空元组即可以判断此时是否已经达到目标参数数量

查看 在线 demo

一些类型编程 tips

判断是否是 never

type isNever<T> = [T] extends [never] ? true : false;

type i1 = isNever<never>; // true
type i2 = isNever<string>; //false

注意 T一定是要被包裹起来,不能是裸类型。因为 TS 会对 extends 左边的裸类型进行分发,但当遇到never时,TS 会认为对never分发是没有意义的,而不再进行逻辑判断,直接返回never

重载函数类型推断

对于具有重载的函数类型,TS 会以最后一个类型定义为准:

declare function foo(x: string): number;
declare function foo(x: number): string;
declare function foo(x: string | number): string | number;

type p1 = Parameters<typeof foo>; // string | number
type r1 = ReturnType<typeof foo>; // string | number

// 上面重载定义等价于
type Foo = {
  (x: string): number;
  (x: number): string;
  (x: string | number): number | string;
};
type p2 = Parameters<Foo>; // string | number
type r2 = ReturnType<Foo>; // string | number

// 也等价于
type Foo1 = ((x: string) => number) &
  ((x: number) => string) &
  ((x: string | number) => number | string);
type p3 = Parameters<Foo>; // string | number
type r3 = ReturnType<Foo>; // string | number

union 转 intersection

当开启strictFunctionTypes后,函数参数位置为逆变。并且在逆变位置上,同一类型变量的多个候选类型将会被推断为交叉类型

type UnionToIntersection<T> = (T extends any ? (x: T) => void : never) extends (
  x: infer R
) => void
  ? R
  : never;

type u1 = UnionToIntersection<"str" | string>; // string
type u2 = UnionToIntersection<"str1" | "str2">; // "str1" & "str2" -> never

union 转 tuple

需要将"a"|"b"|"c"转换成["a","b","c"],需要用到上面所提到的两个 tips:

  1. 首先将 union 转化为函数类型 union
  2. 再利用 union 转 intersection 的特性,将函数 union 变成函数重载
  3. 最后利用重载类型推断时以最后一个为准,就能分离出最后一个 union 类型
  4. 剩下的 union 类型重复上述过程直到结束
type LastOfUnion<T> = UnionToIntersection<
  T extends any ? (x: T) => void : never
> extends (x: infer L) => void
  ? L
  : never;

type UnionToTuple<T, R extends any[] = []> = isNever<T> extends true
  ? R
  : UnionToTuple<Exclude<T, LastOfUnion<T>>, [LastOfUnion<T>, ...R]>;

type t1 = UnionToTuple<"1" | "2" | "3" | "4">; // ["1", "2", "3", "4"]

查看 在线 demo

判断两个类型相等

type IsEqual<T1, T2> = (<U>() => U extends T1 ? true : false) extends <
  U
>() => U extends T2 ? true : false
  ? true
  : false;

type e1 = IsEqual<{ name: string }, { name: string }>; // true
type e2 = IsEqual<{ name: string }, { readonly name: string }>; // false
type e3 = IsEqual<number, string>; // false

为什么是这样的呢?详解见 How does the Equals work in typescript?,已经讲得非常清楚,这里就不在赘述。

打怪练级

打怪练级推荐访问 type-challenges,里面包含了大量的类型编程挑战。相信通关后,你一定能玩转类型体操👍👍👍!

玩转TS类型体操

至此就是本文的全部内容,希望对你有所帮助,如果文中有任何不对的地方,敬请赐教😁。