一、什么是泛型

软件工程中,我们不仅要创建一致的定义良好的API,同时也要考虑可重用性。组件不仅能够支持当前的数据类型,同时也支持未来的数据类型,这在创建大型系统时提供了十分灵活的功能。
在C#和Java这样的语言中,可以使用泛型创建可重用的组件,一个组件可以支持多种类型的数据。这样用户就可以以自己的数据类型来使用组件。
设计泛型的关键目的是在成员之间提供有意义的约束,这些成员可以是:类的实例成员、类的方法、函数参数和函数返回值。

  1. function identity (value: Number) : Number {
  2. return value;
  3. }
  4. console.log(identity(1)) // 1

以上函数的问题是我们将Number类型分配给参数和返回类型,使该函数仅可用于该原始类型。但是该函数并不是可扩展或者通用的,很显然这不是我们希望的。
我们可以把Number换成any,我们失去了定义应该返回哪种类型的能力,并且在这个过程中使编译器失去了类型保护的能力,而我们的目标是让该函数可以适用于任何特定的类型,为了实现这个目标,我们可以使用泛型来解决这个问题:

  1. function identity <T>(value: T) : T {
  2. return value
  3. }
  4. console.log(identity<Number>(1)); // 1

可能对于刚刚接触TypeScript的同学来说,首次看到语法会感到陌生,其实就像是传递参数一样,我们传递了我们想要用于特定函数调用的类型。
aHR0cDovL2Nkbi5zZW1saW5rZXIuY29tL2dlbmVyaWMtdHlwZS1maWxsZWQuanBn.jpeg
参考上面的图片,当我们调用 identity(1) ,Number 类型就像参数 1 一样,它将在出现T的任何位置填充该类型。图中内部的T被称为类型变量,它是希望我们传递给函数的类型占位符,同时它被分配给value参数来代替它的类型:此时T充当的是类型,而不是特定的Number类型。

其中T代表Type,在定义泛型的时候通常作为第一个类型变量的名称,但是实际上T可以用任何有效名称代替。除了T之外,以下是常见泛型变量代表的意思:

  • K(key):表示对象中的键类型;
  • V(value):表示对象中的值类型;
  • E(Element):表示元素类型;

其实并不是只能定义一个类型变量,我们可以引入希望定义的任何数量的类型变量。比如我们引入一个新的类型变量U,用于扩展我们定义的identity函数:

  1. function identity <T, U>(value: T, message: U) : T {
  2. console.log(message);
  3. return value;
  4. }
  5. console.log(identity<Number, string>(68, "Semlinker"));

aHR0cDovL2Nkbi5zZW1saW5rZXIuY29tL2dlbmVyaWMtbXVsdGktdHlwZS1maWxsZWQuanBn.jpeg
除了为类型变量显示设定值之外,一种更常见的做法就是使编译器自动选择这些类型,从而使代码更加简洁,我们可以完全省略掉尖括号:

  1. function identity <T, U>(value: T, message: U) : T {
  2. console.log(message);
  3. return value;
  4. }
  5. console.log(identity(68, "Semlinker"));

相比之前定义的函数,新的identity函数增加了一个类型变量U,但是该函数的返回类型我们仍然使用T。如果我们想要返回两种类型的对象应该怎么办?针对这个问题,我们可以有很多种方案,其中一种就是使用元组,即为元组设置通用的类型:

  1. function identity <T, U>(value: T, message: U) : [T, U] {
  2. return [value, message];
  3. }

虽然使用元组解决了上述的问题,但是有没有其他更好的方案?其实我们还可以使用泛型接口。

二、泛型接口

为了解决上述的问题,首先我们创建一个用于identity函数通用Identities接口:

  1. interface Identities<V, M> {
  2. value: V;
  3. message: M;
  4. }

在上述的Identities接口中,我们引入了类型变量V和M,进一步来说明有效的字母都可以用于表示类型变量,之后我们就可以将Identities接口作为函数的返回类型:

  1. function identity<T, U> (value: T, message: U): Identities<T, U> {
  2. console.log(value + ": " + typeof (value));
  3. console.log(message + ": " + typeof (message));
  4. let identities: Identities<T, U> = {
  5. value,
  6. message
  7. };
  8. return identities;
  9. }
  10. console.log(identity(68, "Semlinker"));

泛型除了可以应用在函数和接口之外,也可以应用在类中,下面我们看以下如何在类中使用泛型。

三、泛型类

在类中使用泛型也比较简单,我们只需要在类名后面使用的语法定义任意多个类型变量,如下:

  1. interface GenericInterface<U> {
  2. value: U
  3. getIdentity: () => U
  4. }
  5. class IdentityClass<T> implements GenericInterface<T> {
  6. value: T
  7. constructor(value: T) {
  8. this.value = value
  9. }
  10. getIdentity(): T {
  11. return this.value
  12. }
  13. }
  14. const myNumberClass = new IdentityClass<Number>(68);
  15. console.log(myNumberClass.getIdentity()); // 68
  16. const myStringClass = new IdentityClass<string>("Semlinker!");
  17. console.log(myStringClass.getIdentity()); // Semlinker!

接下来我们以实例化myNumberClass为例,分析以下调用过程:

  • 在实例化IdentityClass对象时,我们传入了Number类型和构造函数参数值68;
  • 之后在IdentityClass类中,类型变量T的值变成Number类型;
  • IdentityClass类实现了GenericInterface,而此时T表示类型,因此等价于该类实现了GenericInterface接口;
  • 而对于GenericInterface接口来说,变量类型U也变成了Number,这里使用了不同的变量名,以表明类型值沿链向上传播,且与变量名无关;

泛型类可以确保在整个类中一致的使用指定的数据类型,比如在TypeScript的React项目中使用了以下约定:

  1. type Props = {
  2. className?: string
  3. ...
  4. };
  5. type State = {
  6. submitted?: bool
  7. ...
  8. };
  9. class MyComponent extends React.Component<Props, State> {
  10. ...
  11. }

在以上代码中,我们将泛型和React组件一起使用,以确保组件的props和state是安全类型。
那么,我们到底在什么时候需要使用泛型呢?通常在决定是否使用泛型时,我们有以下两个参考标准:

  • 当函数、接口或者类将处理多种数据类型时;
  • 当函数、接口或者类在多个地方使用该数据类型时;

    四、泛型约束

    有时我们希望限制每个类型变量接受的类型数量,这就是泛型约束的作用。

4.1 确保属性存在
有时候,我们希望类型变量上存在某些属性,这个时候,除非我们显式的将特定属性定义为类型变量,否则编译器不知道它们的存在。
在处理字符串或者数组时,我们会假设lenght属性是可用的,当我们尝试输出参数的长度:

  1. function identity<T>(arg: T): T {
  2. console.log(arg.length); // Error
  3. return arg;
  4. }

这种情况下,编译器将不会知道T中含有length属性,尤其是在可以将任何类型赋给类型变量的T的情况下。我们需要做的就是让类型变量extends一个含有所需属性的接口:

  1. interface Length {
  2. length: number;
  3. }
  4. function identity<T extends Length>(arg: T): T {
  5. console.log(arg.length); // 可以获取length属性
  6. return arg;
  7. }

告诉编译器,我们已经支持Length接口的任何类型,之后当我们使用不含有length属性的对象作为参数调用函数时,会报出错误信息:

  1. identity(68); // Error
  2. // Argument of type '68' is not assignable to parameter of type 'Length'.(2345)

此外,我们还可以使用逗号来分隔多种约束类型,例如。而对于上述的length属性问题来说,如果我们显式的将变量设置为数组类型,也可以解决问题:

  1. function identity<T>(arg: T[]): T[] {
  2. console.log(arg.length);
  3. return arg;
  4. }
  5. // or
  6. function identity<T>(arg: Array<T>): Array<T> {
  7. console.log(arg.length);
  8. return arg;
  9. }

4.2 检查对象上的键是否存在
泛型约束的另一个常见的使用场景就是检查对象上的键是否存在。我们先了解一下keyof操作符,keyof操作符可以用于获取某种类型的所有键,其返回类型是联合类型:

  1. interface Person {
  2. name: string;
  3. age: number;
  4. location: string;
  5. }
  6. type K1 = keyof Person; // "name" | "age" | "location"
  7. type K2 = keyof Person[]; // number | "length" | "push" | "concat" | ...
  8. type K3 = keyof { [x: string]: Person }; // string | number

通过keyof操作符,我们可以获取指定类型的所有键,之后我们就可以结合extends约束,即限制输入的属性名包含在keyof返回的联合类型中:

  1. function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  2. return obj[key];
  3. }

在以上的getProperty函数中,我们通过K extends keyof T确保参数key一定是对象中含有的键,这样就不会发生运行时的错误:

  1. enum Difficulty {
  2. Easy,
  3. Intermediate,
  4. Hard
  5. }
  6. function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  7. return obj[key];
  8. }
  9. let tsInfo = {
  10. name: "Typescript",
  11. supersetOf: "Javascript",
  12. difficulty: Difficulty.Intermediate
  13. }
  14. let difficulty: Difficulty = getProperty(tsInfo, 'difficulty'); // OK
  15. let supersetOf: string = getProperty(tsInfo, 'superset_of'); // Error

以上的示例中,对于getProperty(tsInfo, ‘superset_of’) 这个表达式,TypeScript编译器会提示以下错误信息:

  1. Argument of type '"superset_of"' is not assignable to parameter of type '"difficulty" | "name" | "supersetOf"'.(2345)

很明显通过使用泛型约束,在编译阶段我们就可以提前发现错误,大大的提高了程序的健壮性和稳定性。

五、泛型参数默认类型

在TS2.3以后,我们可以为泛型中的参数指定默认类型。当使用泛型时没有在代码中直接指定类型参数,从实际值参数中也无法推断出类型时,这个默认类型就会起作用。
泛型参数默认类型和普通函数默认值类似,对应的语法如下:

  1. interface A<T=string> {
  2. name: T;
  3. }
  4. const strA: A = { name: "Semlinker" };
  5. const numB: A<number> = { name: 101 };

泛型参数的默认类型遵循以下规则:

  • 有默认类型的类型参数被认为是可选的。
  • 必选的类型参数不能在可选的类型参数后。
  • 如果类型参数有约束,类型参数的默认类型必须满足这个约束。
  • 当指定类型实参时,你只需要指定必选类型参数的类型实参。未指定的类型参数会被解析为它们默认的类型。
  • 如果指定了默认类型,且类型推断无法选择一个候选类型,那么将使用默认类型作为推断结果。
  • 一个被现有类或接口合并的类或者接口的声明可以为现有类型参数引入默认类型。
  • 一个被现有类或接口合并的类或者接口的声明引入新的类型参数,只要它制定了默认类型。