TypeScript-泛型

753 阅读9分钟

什么是泛型

泛型(Generics)是指在定义函数、接口、类、类型别名的时候,不预先指定具体的类型,而在使用的时候再指定类型的一种特性。

通俗一点解释,泛型就是类型系统中的“参数”,主要作用是为了类型的重用。从上面定义可以看出,它只会用在函数、接口、类和类型别名中。它和 js 程序中的函数参数是两个层面的事物(虽然意义是相同的),称它为“参数”,是因为它具备和函数参数一模一样的特性。

看一个典型的例子

假设我们定义一个函数,它可以接收一个number类型做为参数,并且返回一个number类型。

function copy(data: number): number {
    return data;
}

按照以上的写法是没有问题的,但是如果我们要接受一个string并返回一个string呢?如果逻辑一样还要在写一遍吗?就像下面这样。

function copy(data: string): string {
    return data;
}

如果再来10种类型,就需要再定义10个函数,这显然代码是很冗余的。在typeScript中要覆盖所有可能性,明显只能选择 any 类型了:

function copy(data: any): any {
    return data;
}

这还挺行得通的,但此刻你的函数实际上丢失了所有类型的概念,你将不能在本该有确定类型信息的地方使用它们了。本质上来说现在你可以传入任何值而编译器将一言不发,这和你使用普通的 JavaScript 就没有区别了(即无论怎样都没有类型信息了)。

我们该如何修复这点并避免使用 any 类型呢?

泛型来拯救

一个泛型就像若干类型的一个变量,这意味着我们可以定义一个表示任何类型的变量,同时能保持住类型信息。后者是关键,因为那正是 any 做不到的。基于这种想法,现在可以这样重构我们的 copy 函数:

//在函数后面用“<>”声明一个类型变量
function copy<T>(data: T):T {
    return data;
}

泛型通过一对尖括号来表示(<>),尖括号内的字符被称为类型变量,这个变量用来表示类型。

 data: T 表示声明参数是 T 类型的,后面的 : T 表示返回值也是 T 类型的。

这个类型 T,在没有调用 copy 函数的时候并不确定,只有调用 copy 的时候,我们才知道 T 具体代表什么类型。

const str = copy<string>('my name is typescript')

我们在 VS Code 中可以看到 copy 函数的参数以及返回值已经有了类型,也就是说我们调用 copy 函数的时候,给类型变量 T 赋值了 string。

多个类型参数

我们在定义范型的时候,也可以一次定义多个类型参数,像下面这样。

function swap<T, U>(data: [T, U]):[U, T] {
    return [data[1], data[0]];}

泛型参数默认类型

TypeScript 2.3 以后,我们可以为泛型中的类型参数指定默认类型。当使用泛型时没有在代码中直接指定类型参数,从实际值参数中也无法推断出类型时,这个默认类型就会起作用。

泛型参数默认类型与普通函数默认值类似,对应的语法很简单,即 <T=Default Type>,对应的使用示例如下:

function copy<T=string>(data: T):T {
    return data;
}

const strA = copy('my name is typescript');
const numB = copy<number>(123);

泛型接口

interface GenericType<T> {
    id: number;
    name: T;
}

function showTypeOne(args: GenericType<string>) {
    console.log(args);
}
showTypeOne({ id: 1, name: 'test' });
// Output: {id: 1, name: "test"}

function showTypeTwo(args: GenericType<number>) {
    console.log(args);
}
showTypeTwo({ id: 1, name: 4 });
// Output: {id: 1, name: 4}

泛型类

泛型类即在声明类的时候声明泛型,那么在类的整个作用域范围内都可以使用声明的泛型类型。我们来新建一个存储数据的类:

class DataStorage {
    private data = [];

    addItem(item) {
        this.data.push(item);
    }
    remove(item) {
        this.data.splice(this.data.indexOf(item), 1);
    }
    getItems() {
        return this.data;
    }
}

由于类的属性和方法参数都没设置类型,所以报了错:

那么我们现在可以通过添加泛型来解决这个问题:

class DataStorage<T> {
    private data: T[] = [];

    addItem(item: T) {
        this.data.push(item);
    }
    remove(item: T) {
        this.data.splice(this.data.indexOf(item), 1);
    }
    getItems() {
        return this.data;
    }
}

const stringStorage = new DataStorage<string>();

stringStorage.addItem('A');
stringStorage.addItem('B');
stringStorage.addItem('C');
stringStorage.removeItem('C');

console.log(stringStorage.getItems());

这里我们把T设置成了string类型,我们可以根据自己的需要传入其它类型。

在React Typescript项目中的写法:

interface IProps {
    className?: string;
    ...
}

interface IState {
    submitted?: bool;
    ...
}

class MyComponent extends React.Component<IProps, IState> {   ...
}

泛型约束

有了泛型之后,一个函数或类能处理的类型一下子扩到了无限大,似乎有点失控的感觉。所以这里又产生了一个约束的概念。下面我们来举几个例子,介绍一下如何使用泛型约束。

确保属性存在

有时候,我们希望类型变量对应的类型上存在某些属性。这时,除非我们显式地将特定属性定义为类型变量,否则编译器不会知道它们的存在。

一个很好的例子是在处理字符串或数组时,我们会假设 length 属性是可用的。让我们再次使用 copy 函数并尝试输出参数的长度:

function copy<T>(data: T): T {
    console.log(data.length); // Error
    return arg
}

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

interface ILength {
  length: number;
}

function copy<T extends ILength>(data: T): T {
    console.log(data.length); // 可以获取length属性
    return data
}

T extends Length 用于告诉编译器,我们支持已经实现 ILength 接口的任何类型。之后,当我们使用不含有 length 属性的对象作为参数调用 copy 函数时,TypeScript会提示相关的错误信息:

copy(68); // Error
// Argument of type '68' is not assignable to parameter of type 'Length'.

此外,我们还可以使用 , 号来分隔多种约束类型,比如:<T extends Length, Type2, Type3>。

检查对象上的键是否存在

泛型约束的另一个常见的使用场景就是检查对象上的键是否存在。不过在看具体示例之前,我们得来了解一下 keyof 操作符,**keyof 操作符是在 TypeScript 2.1 版本引入的,该操作符可以用于获取某种类型的所有键,其返回类型是联合类型。**我们来举个 keyof 的使用示例:

interface IButton {
    type: string
    text: string
}

type ButtonKeys = keyof IButton
// 等效于
type ButtonKeys = "type" | "text"

通过 keyof 操作符,我们就可以获取指定类型的所有键,之后我们就可以结合前面介绍的 extends 约束,即限制输入的属性名包含在 keyof 返回的联合类型中。具体的使用方式如下:

function getValue<T extends object, U extends keyof T>(obj: T, key: U) {
  return obj[key] // ok
}

工具泛型

用 JavaScript 编写中大型程序是离不开 lodash 工具的,而用 TypeScript 编程同样离不开工具泛型的帮助,工具泛型就是类型版的 lodash 。简单的来说,就是把已有的类型经过类型转换构造一个新的类型。工具泛型本身也是类型,得益于泛型的帮助,使其能够对类型进行抽象的处理。工具泛型主要目的是简化类型编程的过程,提高效率。

TypesScript 中内置了很多工具泛型,内置的泛型在 TypeScript 内置的 lib.es5.d.ts 中都有定义,所以不需要任何依赖都是可以直接使用的。下面看看一些经常使用的工具泛型吧。

Partial

Partial<T> 的作用就是将某个类型里的属性全部变为可选项 ?

type Partial<T> = {
    [P in keyof T]?: T[P]
}

在以上代码中,首先通过 keyof T 拿到 T 的所有属性名,然后使用 in 进行遍历,将值赋给 P,最后通过 T[P] 取得相应的属性值。中间的 ? 号,用于将所有属性变为可选。

interface PartialType {
    id: number;
    firstName: string;
    lastName: string;
}

/*
等效于
interface PartialType {
  id?: number
  firstName?: string
  lastName?: string
}
*/

function showType(args: Partial<PartialType>) {
    console.log(args);
}

showType({ id: 1 });
// Output: {id: 1}

showType({ firstName: 'John', lastName: 'Doe' });
// Output: {firstName: "John", lastName: "Doe"}

Required

Required 的作用刚好与 Partial 相反,就是将接口中所有可选的属性改为必须的,区别就是把 Partial 里面的 ? 替换成了 -?

type Required<T> = {
    [P in keyof T]-?: T[P]
}

Record

Record<K, T>可帮助我们构造具有给定类型T的一组属性K的类型。

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

Record 接受两个类型变量,Record 生成的类型具有类型 K 中存在的属性,值为类型 T。这里有一个比较疑惑的点就是给类型 K 加一个类型约束,extends keyof any,我们可以先看看 keyof any 是个什么东西。

类型 K 被约束在 string | number | symbol 中,刚好就是对象的索引的类型,也就是类型 K 只能指定为这几种类型。

我们在业务代码中经常会构造某个对象的数组,但是数组不方便索引,所以我们有时候会把对象的某个字段拿出来作为索引,然后构造一个新的对象。假设有个人员列表的数组,要在人员列表中找到personId为1的人员信息,我们一般通过遍历数组的方式来查找,比较繁琐,为了方便,我们就会把这个数组改写成对象。

interface IPerson {
  personId: string;
  name: string;
  age: number;
}

const personsMap: Record<string, IPerson> = {}
const personsList: IPerson[] = await ajax.get('/persons/list')
// personList = [{
//     personId: '1',//     name: '熊大',
//     age: 10
// }, {
//     personId: '2',
//     name: '熊二',
//     age: 8
// }]
personsMap = _.keyBy(personsList, 'personId');
// personsMap = {
//     '1': {
//          personId: '1',
//          name: '熊大',
//          age: 10
//     },
//     '2': {
//          personId: '2',
//          name: '熊二',
//          age: 8
//     }
// }

Extract

Extract<T, U>用来提取T中可以赋值给U的类型--取交集

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

如果 T 中的类型在 U 存在,则返回,否则抛弃。假设我们两个接口类型,有三个公共的属性,可以通过 Extract 提取这三个公共属性。

interface Worker {
  name: string
  age: number
  email: string
  salary: number
}

interface Student {
  name: string
  age: number
  email: string
  grade: number
}


type CommonKeys = Extract<keyof Worker, keyof Student>
// 'name' | 'age' | 'email'

Exclude

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

Exclude 的作用与之前介绍过的 Extract 刚好相反,如果 T 中的类型在 U 不存在,则返回,否则抛弃。现在我们拿之前的两个类举例,看看 Exclude 的返回结果。

interface Worker {
  name: string
  age: number
  email: string
  salary: number
}

interface Student {
  name: string
  age: number
  email: string
  grade: number
}


type ExcludeKeys = Exclude<keyof Worker, keyof Student>
// 'salary'

Pick

Pick<T, K>主要用于提取接口的某几个属性。

type Pick<T, K extends keyof T> = {
    [P in K]: T[P]
}

我们可以通过 Pick 工具,提取 Todo 接口的两个属性,生成一个新的类型 TodoPreview。

interface Todo {
  title: string
  completed: boolean
  description: string
}

type TodoPreview = Pick<Todo, "title" | "completed">

const todo: TodoPreview = {
  title: 'Clean room',
  completed: false
}

Omit

Omit<T, K>的作用与Pick类型正好相反。 不是选择元素,而是从类型T中删除K个属性。

type Omit<T, K extends keyof any> = Pick<
  T, Exclude<keyof T, K>
>

先通过 Exclude<keyof T, K> 先取出类型 T 中存在,但是 K 不存在的属性,然后再由这些属性构造一个新的类型。还是通过前面的 Todo 案例来说,TodoPreview 类型只需要排除接口的 description 属性即可,写法上比之前 Pick 精简了一些。

interface Todo {
  title: string
  completed: boolean
  description: string
}

type TodoPreview = Omit<Todo, "description">

const todo: TodoPreview = {
  title: 'Clean room',
  completed: false
}

什么时候用泛型

  • 需要作用到很多类型的时候,比如文章前面介绍的copy函数
  • 需要被用到很多地方的时候,比如上面介绍的工具泛型

需要注意的是,如果泛型的使用不能约束功能,或者缩小类型范围,就代表不需要用泛型:

function convert<T>(data: T[]): number {
    return data.length
}

这样用泛型就和any类型没有什么区别