TypeScript——泛型

泛型

1、泛型简介

通过一个identity函数来介绍泛型的基本应用。identity函数也叫作恒等函数,它的返回值永远等于传入的参数。

首先,我们定义一个非泛型版本的identity函数。我们将identity函数的参数类型和返回值类型都定义为number类型。示例如下:

ts 复制代码
function identity(arg: number): number {
    return arg;
}

identity(0);

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

ts 复制代码
function identity(arg: unknown): unknown {
    return arg;
}

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

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

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

ts 复制代码
function identity<T>(arg: T): T {
    return arg;
}

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

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

ts 复制代码
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泛型函数的返回值类型。示例如下:

ts 复制代码
function identity<T>(arg: T): T {
    return arg;
}

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

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

2、形式类型参数

2.1、形式类型参数声明

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

ts 复制代码
<TypeParameter, TypeParameter, ...>

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

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

ts 复制代码
function assign<T, U>(target: T, source: U): T & U {
    // ...
}

2.2、类型参数默认类型

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

ts 复制代码
<T = DefaultType>

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

ts 复制代码
<T = boolean>

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

ts 复制代码
<T, U = T>

2.3、可选的类型参数

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

ts 复制代码
<T = boolean, U> // 错误

<T, U = boolean> // 正确

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

ts 复制代码
<T = U, U = boolean> // 错误

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

3、实际类型参数

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

ts 复制代码
<Type, Type, ...>

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

ts 复制代码
function identity<T>(arg: T): T {
    return arg;
}

identity<number>(1);

identity<Date>(new Date());

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

ts 复制代码
function f<T, U = boolean>() {}

f<string>();

f<string, string>();

4、泛型约束

4.1、泛型约束声明

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

ts 复制代码
<TypeParameter extends ConstraintType>

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

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

ts 复制代码
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

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

ts 复制代码
<TypeParameter extends ConstraintType = DefaultType>

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

ts 复制代码
<T extends number = 0 | 1>

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

ts 复制代码
function f<T extends boolean>() {}

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

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

4.2、泛型约束引用类型参数

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

ts 复制代码
<T, U extends T>

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

ts 复制代码
<T extends U, U>

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

ts 复制代码
<T extends T>               // 错误

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

4.3、基约束

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

  1. 规则一,如果类型参数T声明了泛型约束,且泛型约束为另一个类型参数U,那么类型参数T的基约束为类型参数U。示例如下:
ts 复制代码
<T extends U>    // 类型参数T的基约束为类型参数U
  1. 规则二,如果类型参数T声明了泛型约束,且泛型约束为某一具体类型Type,那么类型参数T的基约束为类型Type。示例如下:
ts 复制代码
<T extends boolean>
  1. 规则三,如果类型参数T没有声明泛型约束,那么类型参数T的基约束为空对象类型字面量"{}"。除了undefined类型和null类型外,其他任何类型都可以赋值给空对象类型字面量。示例如下:
ts 复制代码
<T>       // 类型参数T的基约束为"{}"类型

4.4、常见错误

ts 复制代码
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的类型相同,而不能仅满足泛型约束。

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

ts 复制代码
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类型。

5、泛型函数

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

5.1、泛型函数定义

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

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

ts 复制代码
<T>(x: T): T

在该语法中,T为泛型形式类型参数。

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

ts 复制代码
new <T>(): T[];

在该语法中,T为泛型形式类型参数。

5.2、泛型函数示例

ts 复制代码
function f0<T>(x: T): T {
    return x;
}

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

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

ts 复制代码
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函数接受两个相同类型的参数,函数返回值类型是数组并且数组元素类型与参数类型相同。

ts 复制代码
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类型相同。

ts 复制代码
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返回值类型的数组。

5.3、泛型函数类型推断

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

ts 复制代码
function f0<T>(x: T): T {
    return x;
}

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

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

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

ts 复制代码
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类型更加精确的类型。在实际使用中,我们也正是希望编译器能够尽可能地帮助细化类型。

5.4、泛型函数注意事项

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

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

ts 复制代码
function f<T>(x: T): void {
    console.log(x);
}

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

ts 复制代码
function f0(x: string): void {
    console.log(x);
}

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

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

6、泛型接口

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

ts 复制代码
interface MyArray<T> extends Array<T> {
    first: T | undefined;
    last: T | undefined;
}

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

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

ts 复制代码
const a: Array<number> = [0, 1, 2];

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

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

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

    [n: number]: T;

    // ...
}

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

7、泛型类型别名

7.1、泛型类型别名定义

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

ts 复制代码
type Nullable<T> = T | undefined | null; 

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

7.2、泛型类型别名示例

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

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

ts 复制代码
type Container<T> = { value: T };

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

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

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

ts 复制代码
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
    }
};

8、泛型类

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

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

ts 复制代码
class Container<T> {
    constructor(private readonly data: T) {}
}

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

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

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

ts 复制代码
const Container = class<T> {
    constructor(private readonly data: T) {}
};

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

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

ts 复制代码
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();
    }
}

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

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

    constructor(private readonly data: T) {}
}
相关推荐
Pedantic1 小时前
SwiftUI 手势层级(Gesture Hierarchy)详解
前端
飘尘1 小时前
前端转型全栈(Java后端)的快速上手指引
前端·后端·全栈
一颗烂土豆1 小时前
Meshopt 压缩深度解析,为什么它比 Draco 更快
前端·javascript·webgl
浏览器工程师2 小时前
AI Agent 接浏览器任务,先别让它一路点到底
前端·后端
雨季mo浅忆2 小时前
VSCode自动格式化三要素
前端
爱勇宝3 小时前
深扒 Anthropic 1680 位工程师简历:应届生几乎没机会,AI 公司最缺的不是博士
前端·后端·程序员
kyriewen4 小时前
同事每天催我 Code Review,我写了个脚本让 AI 替我 review PR——现在他反过来催 AI 了
前端·javascript·ai编程
user20585561518136 小时前
Windows 项目安装时报 `node-sass` 错误,如何快速处理
前端
LiaCode6 小时前
Redis 在生产项目的使用
前端·后端