泛型

泛型程序设计是一种编程风格或编程范式,它允许在程序中定义形式类型参数,然后在泛型实例化时使用实际类型参数来替换形式类型参数。通过泛型,我们能够定义通用的数据结构或类型,这些数据结构或类型仅在它们操作的实际类型上有差别。泛型程序设计是实现可重用组件的一种手段。

泛型简介

本节我们将通过一个 identity 函数来介绍泛型的基本应用。identity 函数也叫作恒等函数,它的返回值永远等于传入的参数。首先,我们定义一个非泛型版本的 identity 函数。我们将 identity 函数的参数类型和返回值类型都定义为 number 类型。示例如下:

function identity(arg: number): number {
    return arg;
}

identity(0);

此例中,identity 函数的使用场景非常有限,它只能接受 number 类型的参数。如果想让 identity 函数能够接受任意类型的参数,那么就需要使用顶端类型。例如,下例中我们将 identity 函数的参数类型和返回值类型都声明为 unknown 类型,这样它就可以同时处理 number 类型、string 类型以及对象类型等的值:

function identity(arg: unknown): unknown {
    return arg;
}

identity(0);
identity('foo');
identity({ x: 0, y: 0 });

虽然 any 类型或 unknown 类型能够让 identity 函数变得通用,使其能够接受任意类型的参数,但是却失去了参数类型与返回值类型相同这个重要信息。从 identity 函数声明中我们只能了解到该函数接受任意类型的参数并返回任意类型的值,参数类型与返回值类型之间并无联系。那么,需要有一种方式让我们既能够捕获传入参数的类型,又能够使用捕获的传入参数类型作为函数返回值的类型。这样一来,identity 函数不但能够接受任意类型的参数,还能够保证参数类型与返回值类型是一致的。

接下来,我们尝试给 identity 函数添加一个类型参数。示例如下:

function identity<T>(arg: T): T {
    return arg;
}

此例中,Tidentity 函数的一个类型参数,它能够捕获 identity 函数的参数类型并用作返回值类型。从 identity 函数的类型注解中我们能够观察到,传入参数的类型与返回值类型是相同的类型,两者均为类型 T。我们称该版本的 identity 函数为泛型函数。

在调用 identity 泛型函数时,我们能够为类型参数 T 传入一个实际类型。示例如下:

function identity<T>(arg: T): T {
    return arg;
}

const foo = identity<string>('foo');
//    ~~~
//    能够推断出 'foo' 的类型为 'string'

const bar = identity<string>(true);
//                           ~~~~
//                           编译错误!
//                           类型为 'true' 的参数不能赋值给类型为 'string' 的参数

此例第 5 行,在调用 identity 函数时指定了类型参数T的实际类型为 string 类型,编译器能够推断出返回值的类型也为 string 类型。第 9 行,在调用 identity 函数时,实际类型参数与函数实际参数的类型不兼容,因此产生了错误。

在大部分情况下,程序中不需要显式地指定类型参数的实际类型。TypeScript 编译器能够根据函数调用的实际参数自动地推断出类型参数的实际类型。例如,下例中在调用 identity 泛型函数时没有指定类型参数 T 的实际类型,但是编译器能够根据传入的实际参数的类型推断出泛型类型参数 T 的实际类型,进而又能够推断出 identity 泛型函数的返回值类型。示例如下:

function identity<T>(arg: T): T {
    return arg;
}

const foo = identity('foo');
//                   ~~~~~
//                   能够推断出foo的类型为'foo'

const bar = identity(true);
//                   ~~~~
//                   能够推断出bar的类型为true

形式类型参数

形式类型参数声明

泛型类型参数能够表示绑定到泛型类型或泛型函数调用的某个实际类型。在类声明、接口声明、类型别名声明以及函数声明中都支持定义类型参数。泛型形式类型参数列表定义的具体语法如下所示:

<TypeParameter, TypeParameter, ...>

在该语法中,TypeParameter 表示形式类型参数名,形式类型参数需要置于 < 和 > 符号之间。当同时存在多个形式类型参数时,类型参数之间需要使用逗号 , 进行分隔。

形式类型参数名必须为合法的标识符。形式类型参数名通常以大写字母开头,因为它代表一个类型。在一些编程风格指南中,推荐给形式类型参数取一个具有描述性的名字,如 TResponse,同时还建议形式类型参数名以大写字母 TType 的首字母)作为前缀。另一种流行的命名方法是使用单个大写字母作为形式类型参数名。该风格的命名通常由字母 T 开始,并依次使用后续的 UV 等大写字母。若形式类型参数列表中只存在一个或者少量的类型参数,可以考虑采用该风格,但前提是不能影响程序的可读性。示例如下:

function assign<T, U>(target: T, source: U): T & U {
    // ...
}

类型参数默认类型

在声明形式类型参数时,可以为类型参数设置一个默认类型,这类似于函数默认参数。类型参数默认类型的语法如下所示:

<T = DefaultType>

该语法中,T 为形式类型参数,DefaultType 为类型参数 T 的默认类型,两者之间使用等号连接。例如,下例中形式类型参数 T 的默认类型为 boolean 类型:

<T = boolean>

类型参数的默认类型也可以引用形式类型参数列表中的其他类型参数,但是只能引用在当前类型参数左侧(前面)定义的类型参数。例如,下例中类型参数 U 的默认类型为类型参数 T。因为类型参数 T 是在类型参数 U 之前(左侧)定义的,所以是正确的定义方式,如下所示:

<T, U = T>

可选的类型参数

如果一个形式类型参数没有定义默认类型,那么它是一个必选类型参数;反之,如果一个形式类型参数定义了默认类型,那么它是一个可选的类型参数。在形式类型参数列表中,必选类型参数不允许出现在可选类型参数之后。示例如下:

<T = boolean, U> // 错误

<T, U = boolean> // 正确

编译器以从左至右的顺序依次解析并设置类型参数的默认类型。若一个类型参数的默认类型引用了其左侧声明的类型参数,则没有问题;若一个类型参数的默认类型引用了其右侧声明的类型参数,则会产生编译错误,因为此时引用的类型参数处于未定义的状态。示例如下:

<T = U, U = boolean> // 错误

<T = boolean, U = T> // 正确

实际类型参数

在引用泛型类型时,可以传入一个实际类型参数作为形式类型参数的值,该过程称作泛型的实例化。传入实际类型参数的语法如下所示:

<Type, Type, ...>

在该语法中,实际类型参数列表置于 < 和 > 符号之间;Type 表示一个实际类型参数,如原始类型、接口类型等;多个实际类型参数之间使用逗号 , 分隔。示例如下:

function identity<T>(arg: T): T {
    return arg;
}

identity<number>(1);

identity<Date>(new Date());

当显式地传入实际类型参数时,只有必选类型参数是一定要提供的,可选类型参数可以被省略,这时可选类型参数将使用其默认类型。例如,下例的泛型函数 fT 是必选类型参数,U 是可选类型参数。在调用泛型函数 f 时,允许只为形式类型参数 T 传入实际类型参数,这时形式类型参数 U 将使用默认类型 boolean。示例如下:

function f<T, U = boolean>() {}

f<string>();

f<string, string>();

泛型约束

泛型约束声明

在泛型的形式类型参数上允许定义一个约束条件,它能够限定类型参数的实际类型的最大范围。我们将类型参数的约束条件称为泛型约束。定义泛型约束的语法如下所示:

<TypeParameter extends ConstraintType>

该语法中,TypeParameter 表示形式类型参数名;extends 是关键字;ConstraintType 表示一个类型,该类型用于约束 TypeParameter 的可选类型范围。

下例第 6 行,我们使用 Point 类型来约束形式类型参数 T。这意味着实际类型参数必须是 Point 类型的子类型。第 11 和 12 行,函数的实际参数都是 Point 类型的子类型,并能够赋值给 Point 类型,因此没有错误。第 14 行,函数的实际参数 { x: 0 } 不是 Point 类型的子类型且不能赋值给 Point 类型,所以产生了编译错误。关于类型兼容性的详细介绍请参考 7.1 节。示例如下:

interface Point {
    x: number;
    y: number;
}

function identity<T extends Point>(x: T): T {
    return x;
}

// 正确
identity({ x: 0, y: 0 });
identity({ x: 0, y: 0, z: 0 });

identity({ x: 0 });
//       ~~~~~~~~
//       编译错误!类型 '{ x: number; }' 不能赋值给类型 Point

对于一个形式类型参数,可以同时定义泛型约束和默认类型,但默认类型必须满足泛型约束。具体语法如下所示:

<TypeParameter extends ConstraintType = DefaultType>

在该语法中,默认类型位于泛型约束之后。例如,下例中类型参数 T 的泛型约束为 number 类型,默认类型为数字字面量类型的联合类型 0 | 1

<T extends number = 0 | 1>

如果泛型形式类型参数定义了泛型约束,那么传入的实际类型参数必须符合泛型约束,否则将产生错误。示例如下:

function f<T extends boolean>() {}

f<true>();
f<false>();
f<boolean>();

f<string>(); // 编译错误

泛型约束引用类型参数

在泛型约束中,约束类型允许引用当前形式类型参数列表中的其他类型参数。例如,下例中形式类型参数 U 引用了在其左侧定义的形式类型参数 T 作为约束类型:

<T, U extends T>

下例中,形式类型参数 T 引用了在其右侧定义的形式类型参数 U

<T extends U, U>

需要注意的是,一个形式类型参数不允许直接或间接地将其自身作为约束类型,否则将产生循环引用的编译错误。例如,下例中的泛型约束定义都是错误的:

<T extends T>               // 错误

<T extends U, U extends T>  // 错误

基约束

本质上,每个类型参数都有一个基约束(Base Constraint),它与是否在形式类型参数上定义了泛型约束无关。类型参数的实际类型一定是其基约束的子类型。对于任意的类型参数 T,其基约束的计算规则有三个。

规则一,如果类型参数 T 声明了泛型约束,且泛型约束为另一个类型参数 U,那么类型参数 T 的基约束为类型参数 U。示例如下:

<T extends U>    // 类型参数T的基约束为类型参数U

规则二,如果类型参数 T 声明了泛型约束,且泛型约束为某一具体类型 Type,那么类型参数 T 的基约束为类型 Type。示例如下:

<T extends boolean>

规则三,如果类型参数 T 没有声明泛型约束,那么类型参数 T 的基约束为空对象类型字面量 {}。除了 undefined 类型和 null 类型外,其他任何类型都可以赋值给空对象类型字面量。示例如下:

<T>       // 类型参数T的基约束为"{}"类型

关于空对象类型字面量的详细介绍请参考 5.11.3.5 节。

常见错误

下面的代码演示了在使用泛型约束时容易出现的一个错误:

interface Point {
    x: number;
    y: number;
}

function f<T extends Point>(arg: T): T {
    return { x: 0, y: 0 };
//  ~~~~~~~~~~~~~~~~~~~~~~
//  编译错误!类型 '{ x: number; y: number; }' 不能赋值给类型 'T'
}

此例第 7 行,第一感觉可能是这段代码没有错误,因为返回值 { x: 0, y: 0 } 的类型是泛型约束 Point 类型的子类型。实际上,这段代码是错误的,因为 f 函数的返回值类型应该与传入参数 arg 的类型相同,而不能仅满足泛型约束。

从下例中可以更容易地发现问题所在:

function f<T extends boolean>(obj: T): T {
    return true;
}

f<false>(false); // 返回值类型应该为false

此例中,泛型函数 f 的泛型约束为 boolean 类型,函数 f 的参数类型和返回值类型相同,均为类型参数 T,函数体中直接返回了 true 值。第 5 行,调用泛型函数 f 时传入了实际类型参数为 false 类型。因此,函数 f 的参数类型和返回值类型均为 false 类型。但实际上根据泛型函数 f 的实现,其返回值类型为 true 类型。

泛型函数

若一个函数的函数签名中带有类型参数,那么它是一个泛型函数。泛型函数中的类型参数用来描述不同参数之间以及参数和函数返回值之间的关系。泛型函数中的类型参数既可以用于形式参数的类型,也可以用于函数返回值类型。

泛型函数定义

函数签名分为调用签名和构造签名。这两种函数签名都支持定义类型参数。

定义泛型调用签名的语法如下所示:

<T>(x: T): T

在该语法中,T 为泛型形式类型参数。关于调用签名的详细介绍请参考 5.12.8 节。

定义泛型构造签名的语法如下所示:

new <T>(): T[];

在该语法中,T 为泛型形式类型参数。关于构造签名的详细介绍请参考 5.12.10 节。

泛型函数示例

下面我们再列举一些泛型函数定义与使用的例子。

示例1

function f0<T>(x: T): T {
    return x;
}

const a: string = f0<string>('a');
const b: number = f0<number>(0);

f0 函数接受任意类型的参数 x,并且返回值类型与参数类型相同。

示例2

function f1<T>(x: T, y: T): T[] {
    return [x, y];
}

const a: number[] = f1<number>(0, 1);
const b: boolean[] = f1<boolean>(true, false);

f1 函数接受两个相同类型的参数,函数返回值类型是数组并且数组元素类型与参数类型相同。

示例3

function f2<T, U>(x: T, y: U): { x: T; y: U } {
    return { x, y };
}

const a: { x: string; y: number } = f2<string, number>('a', 0);
const b: { x: string; y: string } = f2<string, string>('a', 'aa');

f2 函数接受两个不同类型的参数,并且返回值类型为对象类型。返回值对象类型中 x 属性的类型与参数 x 类型相同,y 属性的类型与参数 y 类型相同。

示例4

function f3<T, U>(a: T[], f: (x: T) => U): U[] {
    return a.map(f);
}

const a: boolean[] = f3<number, boolean>([0, 1, 2], n => !!n);

f3 函数接受两个参数,参数 a 为任意类型的数组;参数 f 是一个函数,该函数的参数类型与参数 a 的类型相同,并返回任意类型。f3 函数的返回值类型为参数 f 返回值类型的数组。

泛型函数类型推断

在上一节的所有示例中,我们在调用泛型函数时都显式地指定了实际类型参数。示例如下:

function f0<T>(x: T): T {
    return x;
}

const a: string = f0<string>('a');

此例第 5 行,调用 f0 函数时显式地传入了 string 类型作为实际类型参数。

在大部分情况下,TypeScript 编译器能够自动推断出泛型函数的实际类型参数。如果在上例中没有传入实际类型参数,编译器也能够推断出实际类型参数,甚至比显式指定实际类型参数更加精确。示例如下:

function f0<T>(x: T): T {
    return x;
}

const a = f0('a');
//        ~~
//        推断出实际类型参数为:'a'

const b = f0('b');
//    ~
//    推断出 b 的类型为 'b' 而不是 string

此例第 5 行,在调用泛型函数 f0 时没有传入实际类型参数,但是编译器能够推断出实际类型参数 T 为字符串字面量类型 'a'。与此同时,编译器也能够推断出常量 a 的类型为字符串字面量类型 'a',因为泛型函数 f0 的返回值类型为字符串字面量类型 'a'。

另一点值得注意的是,此例中编译器推断出的实际类型参数不是 string 类型,而是字符串字面量类型 'a' 和 'b'。因为 TypeScript 有一个原则,始终将字面量视为字面量类型,只在必要的时候才会将字面量类型放宽为某种基础类型,例如 string 类型。此例中,字符串字面量类型 'a' 是比 string 类型更加精确的类型。在实际使用中,我们也正是希望编译器能够尽可能地帮助细化类型。

关于类型放宽的详细介绍请参考 7.4 节。

泛型函数注意事项

有些泛型函数完全可以定义为非泛型函数,也就是说没有必要使用泛型函数。如果一个函数既可以定义为非泛型函数,又可以定义为泛型函数,那么推荐使用非泛型函数的形式,因为它会更简洁也更易于理解。

当泛型函数的类型参数只在函数签名中出现了一次(自身定义除外)时,该泛型函数是非必要的。示例如下:

function f<T>(x: T): void {
    console.log(x);
}

首先,函数 f 是一个合法的泛型函数。此例中,在类型参数声明 <T> 之外,类型参数 T 只出现了一次,即 (x: T)。在这种情况下,泛型函数就不是必需的,完全可以通过非泛型函数来实现相同的功能。示例如下:

function f0(x: string): void {
    console.log(x);
}

function f1(x: any): void {
    console.log(x);
}

该问题的实质是,泛型函数的类型参数是用来关联多个不同值的类型的,如果一个类型参数只在函数签名中出现一次,则说明它与其他值没有关联,因此不需要使用类型参数,直接声明实际类型即可。从技术上讲,几乎任何函数都可以声明为泛型函数。若泛型函数的类型参数不表示参数之间或参数与返回值之间的某种关系,那么使用泛型函数可能是一种反模式。

泛型接口

若接口的定义中带有类型参数,那么它是泛型接口。在泛型接口定义中,形式类型参数列表紧随接口名之后。泛型接口定义的语法如下所示:

interface MyArray<T> extends Array<T> {
    first: T | undefined;
    last: T | undefined;
}

此例中,我们定义了泛型接口 MyArray,它包含一个类型参数 T。类型参数既可以用在接口的 extends 语句中,如 Array<T>,也可以用在接口类型成员上,如 first: T |undefined

在引用泛型接口时,必须指定实际类型参数,除非类型参数定义了默认类型。示例如下:

const a: Array<number> = [0, 1, 2];

此例中,我们使用泛型声明了数组类型,常量 a 是一个数字数组。值得一提的是,另一种声明数组类型的方式为 number[]

使用泛型是声明数组类型的两种方式之一,例如 Array<number>Array<T>TypeScript 内置的泛型数组类型,它的定义如下所示(从 TypeScript 源码中摘取部分代码):

interface Array<T> {
    pop(): T | undefined;
    push(...items: T[]): number;
    reverse(): T[];

    [n: number]: T;

    // ...
}

Array<T> 泛型接口类型中,类型参数 T 表示数组元素类型。在接口中的方法签名和索引签名中都引用了类型参数 T。例如,reverse 方法会反转数组元素,它的返回值仍为由原数组元素构成的数组。因此,reverse 方法的返回值类型是 T[],即由原数组元素类型构成的数组类型。

泛型类型别名

若类型别名的定义中带有类型参数,那么它是泛型类型别名。

泛型类型别名定义

在泛型类型别名定义中,形式类型参数列表紧随类型别名的名字之后。泛型类型别名定义的语法如下所示:

type Nullable<T> = T | undefined | null;

此例中,定义了一个名为 Nullable 的泛型类型别名,它有一个形式类型参数 T。该泛型类型别名表示可以为空的 T 类型,即 Nullable<T> 类型的值也可以为 undefinednull

泛型类型别名示例

在引用泛型类型别名表示的类型时,必须指定实际类型参数。接下来,我们再列举一些泛型类型别名定义与使用的例子。

示例1 使用泛型类型别名定义简单容器类型,如下所示:

type Container<T> = { value: T };

const a: Container<number> = { value: 0 };

const b: Container<string> = { value: 'b' };

示例2 使用泛型类型别名定义树形结构,如下所示:

type Tree<T> = {
    value: T;
    left: Tree<T> | null;
    right: Tree<T> | null;
};

const tree: Tree<number> = {
    value: 0,
    left: {
        value: 1,
        left: {
            value: 3,
            left: null,
            right: null
        },
        right: {
            value: 4,
            left: null,
            right: null
        }
    },
    right: {
        value: 2,
        left: null,
        right: null
    }
};

泛型类

若类的定义中带有类型参数,那么它是泛型类。

在泛型类定义中,形式类型参数列表紧随类名之后。定义泛型类的语法如下所示:

class Container<T> {
    constructor(private readonly data: T) {}
}

const a = new Container<boolean>(true);
const b = new Container<number>(0);

此例中,我们定义了泛型类 Container<T>,它有一个类型参数 T

上例中,我们使用的是类声明,另一种定义类的方式是类表达式。同样地,类表达式也可以带有类型参数,语法如下所示:

const Container = class<T> {
    constructor(private readonly data: T) {}
};

const a = new Container<boolean>(true);
const b = new Container<number>(0);

泛型类中的类型参数允许在类的继承语句和接口实现语句中使用,即 extends 语句和 implements 语句。例如,下例中分别定义了泛型接口 A 和泛型类 BaseDerived。其中,泛型类 Derived 继承了泛型类 Base 并且实现了泛型接口 A。第 9 行,在泛型类 Derived 中定义的类型参数 T 允许在基类和实现的接口中引用。示例如下:

interface A<T> {
    a: T;
}

class Base<T> {
    b?: T;
}

class Derived<T> extends Base<T> implements A<T> {
    constructor(public readonly a: T) {
        super();
    }
}

在 5.15.14 节中介绍过,每个类声明都会创建两种类型,即类的实例类型和类的构造函数类型。泛型类描述的是类的实例类型。因为类的静态成员是类构造函数类型的一部分,所以泛型类型参数不能用于类的静态成员。也就是说,在类的静态成员中不允许引用类型参数。示例如下:

class Container<T> {
    static version: T;
    //              ~
    //              编译错误!静态成员不允许引用类型参数

    constructor(private readonly data: T) {}
}