TypeScript泛型编程一览

2,268 阅读10分钟

对于初学者来说,TypeScript 可能只是为我们所定义的各种变量添加一个类型而且,实际上相对于 JavaScript 来说变化并不大。但是会经常遇到类型提示不正确,不准确的问题,并且书写过程中无法灵活应用,总是复制来复制去,深入了解下去以后,个人觉得泛型才是 TypeScript 中的精髓,但它并不是简单的复用类型,要想灵活应用,市面上却很少有关于这方面的教程,文档也是语焉不详。

偶然在网上找到了一个泛型编程的练习,做完了上面所有简单和中等的题目,感慨泛型其实和普通的语法非常相似,于是站在这个角度来看问题的时候,把泛型看作是编程语言看待,或许会更好理解。

关键字

keyof

keyof 用来遍历接口类型的键名,即将接口类型转换为联合类型

interface Type {
  name: string;
  age: number;
  sex: string;
}

type Person = keyof Type;

// p只能取接口的键,也就是name或者是age
const p:Person = 'name';
// const p:Person = 'age'

in

in 关键字用来遍历联合类型,通常只能在接口类型中进行遍历

type Union = 'name' | 'age' | 'sex';

type ForEach = {
  [K in Union]: K
};
// 得到的结果是这样的
// {
//   name: 'name',
//   age: 'age',
//   sex: 'sex'
// }

extends

extends 关键字在泛型中有两种含义,一种就是继承关系,另外一种则是约束关系,对于泛型参数的一些限制条件,同时也可以作为判断,也就是下面说到的条件判断的基础。

// 当使用某个泛型T,并且希望T具备某些能力,诸如数组能力、字符串能力,对象的某个属性的能力
interface Animal {
  eat: (name: string) => void
}

type Dog<T extends Animal> = {}; // 表示传递给Dog的类型T必须具备Animal上的属性

infer

这里 infer 放到最后是因为 infer 是 TypeScript 特有的关键字,也是泛型编程中才有的,从字面上解释,infer 就是推断的意思,可以理解为推断类型。

type Foo = (name: string) => number;

type Param<T> = T extends (name: infer P) => any ? P : never;
type Return<T> = T extends (...args: unknown[]) => infer R ? R : never;

// 这里其实就是内置类型Parameters<T>和ReturnType<T> 的实现
type Result = Param<Foo>; // string
type Result1 = Return<Foo>; // number

// 推导数组
type Arr = [1,'str',false,Symbol];
type First<T> = T extends [infer F, ...infer Other] ? F : never;
type Last<T> = T extends [...infer Other, infer L] ? L : never;

type Result2 = First<Arr>; // 1
type Result3 = Last<Arr>; // Symbol

// 推导字符串
type Str = 'abcd';
type Capital<T extends string> = T extends `${infer F}${infer Other}` ? `${Uppercase<F>}${Other}` : T;

type Result4 = Capital<Str>; // Abcd

as

as 在泛型编程中使用的比较少,也是作为类型断言的功能来使用的,下面看个例子

interface Foo {
  [key: string]: any;
  foo(): void;
}

type IndexSignature<T> = {
  [P in keyof T as string]: T[P]
};

type Result = IndexSignature<Foo>; // { [key: string]: any }

as 就是针对这种动态键,加上了as string才能选中[key: string],否则的话,选中的还是全部的键值对。

修饰符-

修饰符是指在接口类型的键名前面添加的修饰性的词,诸如 readonly、可选属性这种。实际上 TypeScript 中虽然有提到,但是并没有说明这种叫什么,所以我就自作主张,类比编程语言,就用了修饰符这个词。

有修饰,就有取消修饰,针对 readonly、required 这种,怎么去取消只读和必须的修饰呢,可以使用“-”这个符号,我自己称之为取反修饰符。又是一个在 TypeScript 文档中没有提到的内容。

interface Person {
  readonly name: string;
  readonly age: number;
}

type CancelOnly<T> = {
  -readonly [K in keyof T]: T[K]
};

type Test = CancelOnly<Person>; // { name: string; age: nunber }

// 对于可选属性也是一样,使用-符号取消可选
interface Person1 {
  name?: string;
  age?: number;
}

type CancelOption<T> = {
  [K in keyof T]-?: T[K]
};

type Test1 = CancelOption<Person1>; // { name: string; age: nunber }

内置类型

内置类型就是 TypeScript 中存在的类型,相当于 JavaScript 中的 Boolean 这种不需要定义的类型,内置类型非常多,这里只讲解一些常用内置类型的实现,加深对泛型编程的理解。

  1. Partial 就是将接口类型中的所有键变成可选,实现如下:
type MyPartial<T> = {
  [K in keyof T]?: T[K]
};
  1. Required 和上面的 Partial 刚好相反,Partial 类型是将接口中的键都变成可选类型,而 Required 则将所有的键变成必选,也就是去掉了?的标记。
// 这里使用了上面提到的修饰符-,取消可选
type MyRequired<T> = {
  [K in keyof T]-?: T[K]
}
  1. Readonly 顾名思义就是将接口类型中所有的键变成只读类型,同理,还有类似 Required 都是一个实现原理
type MyReadonly<T> = {
  readonly [K in keyof T]: T[K]
};
  1. Pick<T, U> 从 T 中选择 U 指定的键值对组成一个新类型
type MyPick<T, U extends keyof T> = {
  [K in U]: T[K]
};
  1. Exclude<T, U> 就是将 U 类型从 T 中排除,只有联合类型才能作为 T 类型使用。可以理解为 Pick 类型的取反,但是类型参数是不一样的。
interface Person {
  name: string;
  age: number;
  height: number;
}

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

// 直接这么去除是无效的
type Test = MyExclude<Person, 'name'>;

// Pick类型则是可以的
type Test1 = MyPick<Person, 'name'>; // { name: string }

// Exclude 可以把T类型变成联合类型
type Test2 = MyExclude<keyof Person, 'name'>; // 得到的结果也是联合类型:'age' | 'height',并不是我们想要的排除的效果

// 可以遍历所有的键,如果键继承了给定的U类型,则排除掉,这样就留下了剩下的键
type IExclude<T, U> = {
  [K in keyof T as K extends U ? never : K]: T[K]
}

type Test3 = IExclude<Person, 'name'>; // { age: number; height: number }
  1. Omit<T, U> 从 T 中删除 U 类型,可以理解为从借口中删除指定的键
interface Todo {
  title: string
  description: string
  completed: boolean
}

type MyOmit<T, U> = {
  [K in keyof T as K extends U ? never : K]: T[K]
}

type Test = MyOmit<Todo, 'description' | 'title'>; // { title: string }

// 结合上面的内置类型,可以发现排除某个键,可以使用 Exclude 来实现
type MyOmit1<T, U> = Pick<T, Exclude<keyof T, 'description' | 'title'>>

// 这里要注意 Exclude 接受的是一个联合类型,因此需要对 T 进行转换

自定义类型

基础类型

常见的基础类型,string | number | boolean | Symbol 以外,还有一些 TypeScript 中特有的类型,如 any、unknown、never、void。

复杂类型

  1. 接口类型
  2. 联合类型

到这里可以把我们的泛型看作是一个函数,诸如 T,K 这种标识符实际上就是我们的函数形参,而实际传递进去的上面提到的这些具体类型就是实参了。

// 以内置类型Pick为例
type Pick<T, U> = {
  [K in U]: T[K]
}

// 可以看成这样
// 这里只是一种假想,是为了便于理解泛型,实际底层可能并不是如此
function Pick (T, U) {
  let res = {}
  for (let K in U) {
    res[K] = T[K]
  }
  return res
}

类型转换

  1. 接口类型转为联合类型

使用 keyof 关键字就可以将接口类型转换为联合类型。而将联合类型转换为接口类型,则可以使用关键字 in。上文已经提到过,这里就不在赘述。

  1. 元组类型转其他类型
type Tuple = ['name','age','sex'];

// 元组类型转为联合类型
type Tuple2Union<T extends unknown[]> = T[number];

// 元组转为对象类型
type TupleToObject<T extends any[]> = {
  [P in T[number]]: P
};

type resullt = TupleToObject<Tuple>;
// {
//   a: "a";
//   b: "b";
//   c: "c";
//   d: "d";
// }

元组可以转成联合类型,那联合类型如何转成元组呢?实际上在尝试以后发现是无法进行转换的,查询了很多资料以后发现,有种解释是比较合理的

联合类型表示的是所有类型中的一种,而元组包含了所有类型,从或到与的关系是冲突的,所以无法进行转换。

  1. 字符串类型转换其他类型
// 字符串类型转换为元组
// 这里还用到了类似于函数默认参数的类型P,用来构造元组类型
type String2Tuple<T extends string, P extends unknown[] = []> = 
  T extends `${infer F}${infer Rest}`
    ? String2Tuple<Rest, [...P, F]>
    : P

type Result = String2Tuple<'abc'>; // ['a','b','c']

// 结合上面元组转成联合类型的方法,也可以把字符串转成联合类型
type String2Union<T extends string> = String2Tuple<T>[number]

流程控制

优先级

逻辑关系

interface Person {
  name: string;
  age: number;
  sex: string;
}

interface Height {
  height: number;
  face: string;
  name: string;
}

type And<T, U> = T & U;

type Or<T, U> = T | U;

type PersonOfKeys = keyof Person;
type HeightOfKeys = keyof Height;

const case1: And<Person, Height> = {
  name: 'aaa',
  age: 20,
  sex: 'female',
  height: 170,
  face: '😂'
}; // 与的关系

const case2: Or<Person, Height> = {name: 'aaaa', age: 123, sex: 'vvv'}; // 或的关系

const case3: And<PersonOfKeys, HeightOfKeys> = 'name'; // 交集中的元素

const case4: Or<PersonOfKeys, HeightOfKeys> = 'age'; // 并集中任意元素

遍历

  1. 遍历interface

使用keyof得到接口的键名

  1. 遍历联合类型

使用in关键字

接口interface和联合类型的互相转换

interface到联合类型,实际就是使用keyof得到interface的键名的联合类型

而联合类型转为interface,则可以这么做:

type ForEach<T extends keyof any> = {
  [K in T]: string
};

type Result = ForEach<Union>;
// {
//   name: string,
//   age: string,
//   sex: string
// }
  1. 遍历元组

可以把元组当作数组来理解,遍历元素实际就是取出元组中每个索引对应的值

type arr = ['a','b','c','d'];

// 把元素当作interface来理解,元素实际就是
// {
//   0: 'a',
//   1: 'b',
//   2: 'c',
//   3: 'd'
// }
// 所以也可以直接用数字来获取对应的类型
type Type = arr[0]; // 'a'

// 遍历所有的数字索引,可以用number来代替
// 注意这里的泛型约束,表示T类型必须继承任意的数组类型,才能取number类型的索引
type ForEachTuple<T extends any[]> = T[number];

type Result = ForEachTuple<arr>; // 'a' | 'b' | 'c' | 'd'

遍历元组的关键就是用number这个类型去取元素中每一项的类型,得到的结果就是一个联合类型

既然把元组当作数组来理解,那是不是可以获取元组的长度呢?

type Length<T extends any[]> = T['length'];

type Result = Length<arr>; // 4

条件判断

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

type Result = IsString<'abc'>; // true

这种三元表达式的判断很好理解,但是还存在一些特殊情况:

首先是对于 never 类型的判断,如何判断某个类型是不是 never 类型

type IsNever<T> = T extends never ? true : false;

type A = IsNever<never>; // never

结果却大失所望,这么判断还是 never 类型,为什么会这样呢?我觉得主要是因为 never 类型本身就代表一种不存在的类型,可以类比 Error 类型,出现 never 代表程序已经出错,自然无法进行下面的判断了。

那么如何判断是否是 never 类型呢,可以对 never 进行一层包装:

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

再来看一个复杂的判断,如何判断类型是否是联合类型

type IsUnion<T> = 
  T[] extends (T extends unknown ? T[] : never)
    ? false
    : true;

首先判断T extends unknown ? T[] : never这句,利用一个中间类型看看这句得到的类型是什么样子的

type Middle<T> = T extends unknown ? T[] : never;

type case = Middle<string>; // string[]
type case1 = Middle<number|string>; // number[]|string[]
type case2 = Middle<boolean>; // false[]|true[]

如此结合上面的IsUnion判断T是否继承case,如果是联合类型自然不会继承,结果也就是true了。

但是上面还是存在了一个特例,那就是boolean类型,经过中间泛型得到的结果和联合是一样的,因此最后返回的结果也是true

所以需要对 boolean 类型再次做个判断

type IsUnion<T> = 
  T[] extends (T extends unknown ? T[] : never)
    ? false
    : T extends boolean
      ? false
      : true;

递归

type Space = ' ' | '\n' | '\t';

// 可以这么理解:类型T如果存在空格,把空格摘出去,剩下的字符再递归进行trim
type Trim<T extends string> = T extends `${Space}${infer P}${Space}` ? Trim<P> : T;

type trimed = Trim<'  Hello World  '>; // 'Hello World'

递归理解比较简单,但是需要注意 TypeScript 中的递归也存在递归深度问题,如果超出一定的层级,则会递归失败,所以需要性能也是泛型编程要考虑的问题。

// 注意递归退出条件:类型U的长度等于给定的长度的时候,就停止递归
// 每次递归的时候将一个数添加到数组中
type ConstructTuple<T extends number, U extends number[] = []> = 
    U['length'] extends T
        ? U
        : ConstructTuple<T, [number, ...U]>
type MinusOne<T extends number> = ConstructTuple<T> extends [infer _, ...infer Other] ? Other['length'] : 0;

type Zero = MinusOne<1> // 0
type Five = MinusOne<5> // 4
// type FourFive = MinusOne<45> // 超过一定的递归调用栈,会报错,递归层级过深

尾声

关于泛型编程,还是需要在平时项目中多去练习,灵活应用才关键。很多泛型构建其实不止一种方法,找到更简单,易懂的泛型构建才是泛型编程追求的目标。

后续也会将文中提到的🌰整理放入github中,因所学有限,欢迎各位大佬继续补充泛型编程中更好的用法。