泛型

软件工程的一个重要部分是构建不仅定义良好且一致的 API,而且还要具有可重用性。能够处理当今数据以及未来数据的组件,将为您构建大型软件系统提供最灵活的能力。

在 C# 和 Java 等语言中,创建可重用组件的常用工具之一是泛型,即能够创建一个可以处理多种类型而非单一类型的组件。这使得用户可以消费这些组件并使用他们自己的类型。

泛型的 Hello World

首先,让我们来看泛型的“Hello World”:恒等函数(identity function)。恒等函数是一个会返回任何传入内容的函数。你可以将其类比为 echo 命令。

如果不使用泛型,我们要么必须给恒等函数指定一个具体的类型:

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

要么,我们可以使用 any 类型来描述恒等函数:

ts
function identity(arg: any): any {
return arg;
}
Try

虽然使用 any 在某种程度上是泛型的,因为它使函数能够接受 arg 的任何和所有类型,但我们在函数返回时实际上丢失了关于该类型是什么的信息。如果我们传入一个数字,我们所知道的唯一信息就是可以返回任何类型。

相反,我们需要一种方法来捕获参数的类型,以便可以用它来表示返回的内容。在这里,我们将使用类型变量,这是一种作用于类型而不是值的特殊变量。

ts
function identity<Type>(arg: Type): Type {
return arg;
}
Try

现在,我们为恒等函数添加了一个类型变量 Type。这个 Type 允许我们捕获用户提供的类型(例如 number),以便我们稍后可以使用该信息。在这里,我们再次使用 Type 作为返回类型。通过检查,我们可以看到相同的类型被用于参数和返回类型。这使我们能够将类型信息从函数的一侧传入,并从另一侧传出。

我们称这个版本的 identity 函数是泛型的,因为它适用于多种类型。与使用 any 不同,它也和第一个使用数字作为参数和返回类型的 identity 函数一样精确(即它不会丢失任何信息)。

一旦我们编写了泛型恒等函数,我们可以通过以下两种方式之一来调用它。第一种方式是将所有参数,包括类型参数,传给函数:

ts
let output = identity<string>("myString");
let output: string
Try

这里我们明确将 Type 设置为 string 作为函数调用的参数之一,并使用 <> 包裹参数而不是 ()

第二种方式也许是最常见的。这里我们使用类型参数推断——也就是说,我们希望编译器根据我们传入的参数类型自动为我们设置 Type 的值:

ts
let output = identity("myString");
let output: string
Try

注意,我们不必在尖括号(<>)中显式传递类型;编译器只是查看了值 "myString",并将 Type 设置为其类型。虽然类型参数推断是一个有助于保持代码简洁和易读的工具,但在编译器无法推断类型时(例如在更复杂的例子中),你可能需要像之前的例子那样显式传递类型参数。

使用泛型类型变量

当你开始使用泛型时,你会注意到当你创建像 identity 这样的泛型函数时,编译器会强制要求你在函数体内正确使用任何泛型类型参数。也就是说,你需要像对待“任何和所有类型”一样对待这些参数。

让我们以之前的 identity 函数为例:

ts
function identity<Type>(arg: Type): Type {
return arg;
}
Try

如果我们还想在每次调用时将参数 arg 的长度记录到控制台呢?我们可能会尝试这样写:

ts
function loggingIdentity<Type>(arg: Type): Type {
console.log(arg.length);
Property 'length' does not exist on type 'Type'.2339Property 'length' does not exist on type 'Type'.
return arg;
}
Try

当我们这样做时,编译器会报错,因为我们使用了 arg.length 成员,但我们并没有在任何地方声明 arg 具有该成员。请记住,我们之前说过这些类型变量代表的是任何和所有类型,所以使用此函数的人可能传入了一个 number,而 number 并没有 .length 成员。

假设我们实际上是想让这个函数处理 Type 的数组而不是直接处理 Type。既然我们是在处理数组,那么 .length 成员应该是可用的。我们可以像创建其他类型的数组那样描述它:

ts
function loggingIdentity<Type>(arg: Type[]): Type[] {
console.log(arg.length);
return arg;
}
Try

你可以这样读取 loggingIdentity 的类型:“泛型函数 loggingIdentity 接收一个类型参数 Type,以及一个参数 arg(它是 Type 的数组),并返回一个 Type 的数组。” 如果我们传入一个数字数组,我们将得到一个数字数组返回,因为 Type 会绑定到 number。这允许我们将泛型类型变量 Type 用作我们处理的类型的一部分,而不是整个类型,从而赋予我们更大的灵活性。

我们也可以用这种方式编写同样的示例:

ts
function loggingIdentity<Type>(arg: Array<Type>): Array<Type> {
console.log(arg.length); // Array has a .length, so no more error
return arg;
}
Try

你可能已经熟悉了其他语言中这种风格的类型。在下一节中,我们将介绍如何创建像 Array<Type> 这样的自定义泛型类型。

泛型类型

在前几节中,我们创建了适用于多种类型的泛型恒等函数。在本节中,我们将探讨函数本身的类型以及如何创建泛型接口。

泛型函数的类型与非泛型函数很像,类型参数列在最前面,类似于函数声明:

ts
function identity<Type>(arg: Type): Type {
return arg;
}
 
let myIdentity: <Type>(arg: Type) => Type = identity;
Try

我们也可以在类型中使用不同的名称作为泛型类型参数,只要类型变量的数量和使用方式对齐即可。

ts
function identity<Type>(arg: Type): Type {
return arg;
}
 
let myIdentity: <Input>(arg: Input) => Input = identity;
Try

我们还可以将泛型类型编写为对象字面量类型的调用签名:

ts
function identity<Type>(arg: Type): Type {
return arg;
}
 
let myIdentity: { <Type>(arg: Type): Type } = identity;
Try

这引导我们编写第一个泛型接口。让我们从上一个例子中获取对象字面量并将其移动到接口中:

ts
interface GenericIdentityFn {
<Type>(arg: Type): Type;
}
 
function identity<Type>(arg: Type): Type {
return arg;
}
 
let myIdentity: GenericIdentityFn = identity;
Try

在类似的例子中,我们可能想将泛型参数设为整个接口的参数。这让我们能清楚地看到我们泛型化的类型(例如 Dictionary<string> 而不仅仅是 Dictionary)。这使得类型参数对接口的所有其他成员可见。

ts
interface GenericIdentityFn<Type> {
(arg: Type): Type;
}
 
function identity<Type>(arg: Type): Type {
return arg;
}
 
let myIdentity: GenericIdentityFn<number> = identity;
Try

请注意,我们的示例已经发生了变化。我们不再描述一个泛型函数,现在我们拥有的是作为泛型类型一部分的非泛型函数签名。当我们使用 GenericIdentityFn 时,现在还需要指定相应的类型参数(这里是 number),从而有效地锁定了底层调用签名将使用的类型。理解何时将类型参数直接放在调用签名上,以及何时将其放在接口本身上,将有助于描述类型的哪些方面是泛型的。

除了泛型接口,我们还可以创建泛型类。请注意,无法创建泛型枚举和命名空间。

泛型类

泛型类的形状与泛型接口类似。泛型类在类名后有尖括号(<>)括起来的泛型类型参数列表。

ts
class GenericNumber<NumType> {
zeroValue: NumType;
add: (x: NumType, y: NumType) => NumType;
}
 
let myGenericNumber = new GenericNumber<number>();
myGenericNumber.zeroValue = 0;
myGenericNumber.add = function (x, y) {
return x + y;
};
Try

这是 GenericNumber 类的一个非常字面的用法,但你可能已经注意到,没有任何东西限制它只能使用 number 类型。我们本来可以使用 string 甚至是更复杂的对象。

ts
let stringNumeric = new GenericNumber<string>();
stringNumeric.zeroValue = "";
stringNumeric.add = function (x, y) {
return x + y;
};
 
console.log(stringNumeric.add(stringNumeric.zeroValue, "test"));
Try

就像接口一样,将类型参数放在类本身上,可以确保类的所有属性都使用相同的类型。

正如我们在关于类的章节中所述,类有两种类型:静态部分和实例部分。泛型类仅在其实例部分是泛型的,而不是静态部分,因此在使用类时,静态成员不能使用类的类型参数。

泛型约束

如果你还记得之前的例子,有时你可能想要编写一个适用于一组类型的泛型函数,前提是你对这组类型具有一些能力上的了解。在我们的 loggingIdentity 例子中,我们希望能够访问 arg.length 属性,但编译器无法证明每个类型都有 .length 属性,因此它警告我们不能做出这种假设。

ts
function loggingIdentity<Type>(arg: Type): Type {
console.log(arg.length);
Property 'length' does not exist on type 'Type'.2339Property 'length' does not exist on type 'Type'.
return arg;
}
Try

我们不想处理任何和所有类型,而是希望将此函数约束为适用于具有 .length 属性的任何类型。只要类型具有此成员,我们就允许它,但要求它至少包含此成员。为此,我们必须将我们的要求作为 Type 可能是什么的约束列出来。

为了实现这一点,我们将创建一个描述我们约束的接口。在这里,我们将创建一个具有单个 .length 属性的接口,然后使用这个接口和 extends 关键字来表示我们的约束:

ts
interface Lengthwise {
length: number;
}
 
function loggingIdentity<Type extends Lengthwise>(arg: Type): Type {
console.log(arg.length); // Now we know it has a .length property, so no more error
return arg;
}
Try

因为泛型函数现在受到了约束,它将不再适用于任何和所有类型:

ts
loggingIdentity(3);
Argument of type 'number' is not assignable to parameter of type 'Lengthwise'.2345Argument of type 'number' is not assignable to parameter of type 'Lengthwise'.
Try

相反,我们需要传入类型具有所有必需属性的值:

ts
loggingIdentity({ length: 10, value: 3 });
Try

在泛型约束中使用类型参数

你可以声明一个受另一个类型参数约束的类型参数。例如,这里我们想从一个对象中根据名称获取一个属性。我们想确保不会意外获取到 obj 上不存在的属性,因此我们将在两个类型之间设置一个约束:

ts
function getProperty<Type, Key extends keyof Type>(obj: Type, key: Key) {
return obj[key];
}
 
let x = { a: 1, b: 2, c: 3, d: 4 };
 
getProperty(x, "a");
getProperty(x, "m");
Argument of type '"m"' is not assignable to parameter of type '"a" | "b" | "c" | "d"'.2345Argument of type '"m"' is not assignable to parameter of type '"a" | "b" | "c" | "d"'.
Try

在泛型中使用类类型

在 TypeScript 中使用泛型创建工厂时,有必要通过其构造函数引用类类型。例如:

ts
function create<Type>(c: { new (): Type }): Type {
return new c();
}
Try

一个更高级的例子使用原型属性来推断和约束构造函数与类类型的实例侧之间的关系。

ts
class BeeKeeper {
hasMask: boolean = true;
}
 
class ZooKeeper {
nametag: string = "Mikle";
}
 
class Animal {
numLegs: number = 4;
}
 
class Bee extends Animal {
numLegs = 6;
keeper: BeeKeeper = new BeeKeeper();
}
 
class Lion extends Animal {
keeper: ZooKeeper = new ZooKeeper();
}
 
function createInstance<A extends Animal>(c: new () => A): A {
return new c();
}
 
createInstance(Lion).keeper.nametag;
createInstance(Bee).keeper.hasMask;
Try

这种模式被用于驱动 Mixins 设计模式。

泛型参数默认值

通过为泛型类型参数声明默认值,你可以使指定相应的类型参数变为可选。例如,一个创建新的 HTMLElement 的函数。在不带参数的情况下调用该函数会生成一个 HTMLDivElement;以一个元素作为第一个参数调用该函数会生成该参数类型的元素。你还可以选择传入一个子元素列表。以前你必须将该函数定义为:

ts
declare function create(): Container<HTMLDivElement, HTMLDivElement[]>;
declare function create<T extends HTMLElement>(element: T): Container<T, T[]>;
declare function create<T extends HTMLElement, U extends HTMLElement>(
element: T,
children: U[]
): Container<T, U[]>;
Try

使用泛型参数默认值,我们可以将其简化为:

ts
declare function create<T extends HTMLElement = HTMLDivElement, U extends HTMLElement[] = T[]>(
element?: T,
children?: U
): Container<T, U>;
 
const div = create();
const div: Container<HTMLDivElement, HTMLDivElement[]>
 
const p = create(new HTMLParagraphElement());
const p: Container<HTMLParagraphElement, HTMLParagraphElement[]>
Try

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

  • 如果类型参数有默认值,则视为可选。
  • 必需的类型参数不能跟在可选的类型参数之后。
  • 如果类型参数有约束,则该参数的默认类型必须满足该约束。
  • 指定类型参数时,你只需要为必需的类型参数指定类型参数。未指定的类型参数将解析为其默认类型。
  • 如果指定了默认类型且推断无法选择候选类型,则推断为默认类型。
  • 与现有类或接口声明合并的类或接口声明可以为现有的类型参数引入默认值。
  • 与现有类或接口声明合并的类或接口声明可以引入新的类型参数,只要它指定了默认值。

可变性注解

这是一项用于解决非常具体问题的高级功能,仅应在你确定有理由使用它时才使用。

协变(Covariance)和逆变(Contravariance)是类型理论术语,用于描述两个泛型类型之间的关系。以下是关于该概念的简要入门。

例如,如果你有一个表示可以 make(生产)某种类型的对象的接口:

ts
interface Producer<T> {
make(): T;
}

当预期为 Producer<Animal> 时,我们可以使用 Producer<Cat>,因为 CatAnimal。这种关系称为协变:从 Producer<T>Producer<U> 的关系与从 TU 的关系相同。

相反,如果你有一个可以 consume(消费)某种类型的接口:

ts
interface Consumer<T> {
consume: (arg: T) => void;
}

那么当预期为 Consumer<Cat> 时,我们可以使用 Consumer<Animal>,因为任何能够接受 Animal 的函数也必须能够接受 Cat。这种关系称为逆变:从 Consumer<T>Consumer<U> 的关系与从 UT 的关系相同。请注意与协变相比方向的逆转!这就是为什么逆变会“自我抵消”而协变不会的原因。

在像 TypeScript 这样的结构化类型系统中,协变和逆变是从类型定义中自然涌现的行为。即使没有泛型,我们也会看到协变(和逆变)关系。

ts
interface AnimalProducer {
make(): Animal;
}
// A CatProducer can be used anywhere an
// Animal producer is expected
interface CatProducer {
make(): Cat;
}

TypeScript 具有结构化类型系统,因此在比较两个类型时(例如查看是否可以在预期 Producer<Animal> 的地方使用 Producer<Cat>),通常的算法是结构化地展开这两个定义并比较它们的结构。然而,可变性(Variance)允许进行一种极其有用的优化:如果 Producer<T>T 是协变的,那么我们只需检查 CatAnimal 即可,因为我们知道它们将具有与 Producer<Cat>Producer<Animal> 相同的关系。

注意,此逻辑仅在检查同一类型的两个实例化时才能使用。如果我们有 Producer<T>FastProducer<U>,则无法保证 TU 一定引用这些类型中的相同位置,因此此检查将始终结构化执行。

因为可变性是结构化类型的自然涌现属性,TypeScript 会自动推断每个泛型类型的可变性。在极少数情况下,涉及某些种类的循环类型时,这种测量可能不准确。如果发生这种情况,你可以向类型参数添加可变性注解以强制执行特定的可变性:

ts
// Contravariant annotation
interface Consumer<in T> {
consume: (arg: T) => void;
}
// Covariant annotation
interface Producer<out T> {
make(): T;
}
// Invariant annotation
interface ProducerConsumer<in out T> {
consume: (arg: T) => void;
make(): T;
}

仅当你编写的可变性与结构上应该发生的可变性一致时才这样做。

永远不要编写与结构可变性不匹配的可变性注解!

至关重要的是要重申,可变性注解仅在基于实例化的比较期间生效。它们在结构比较期间无效。例如,你不能使用可变性注解来“强制”一个类型实际上是不变的(invariant):

ts
// DON'T DO THIS - variance annotation
// does not match structural behavior
interface Producer<in out T> {
make(): T;
}
// Not a type error -- this is a structural
// comparison, so variance annotations are
// not in effect
const p: Producer<string | number> = {
make(): number {
return 42;
}
}

在这里,对象字面量的 make 函数返回 number,我们可能会预期这会导致错误,因为 number 不是 string | number。然而,这不是基于实例化的比较,因为对象字面量是一个匿名类型,而不是 Producer<string | number>

可变性注解不会改变结构行为,且仅在特定情况下被查询。

非常重要的一点是,只有当你绝对知道自己为什么这样做、它们的局限性是什么以及何时不生效时,才编写可变性注解。TypeScript 是使用基于实例化的比较还是结构比较不是一种指定的行为,并且可能会因为正确性或性能原因在版本之间发生变化,因此你应该只在可变性注解与类型的结构行为匹配时才编写它们。不要使用可变性注解来尝试“强制”特定的可变性;这会导致代码中出现不可预知的行为。

不要编写与类型的结构行为不匹配的可变性注解。

请记住,TypeScript 可以自动从你的泛型类型中推断可变性。几乎不需要编写可变性注解,并且你应该仅在确定有特定需求时才这样做。可变性注解不会改变类型的结构行为,并且根据情况,你可能会在期望基于实例化的比较时看到结构比较。可变性注解不能用于修改类型在这些结构上下文中的行为,也不应该在注解与结构定义不一致时编写。因为这很难做到正确,而且 TypeScript 在绝大多数情况下都能正确推断可变性,所以你不应该在常规代码中编写可变性注解。

不要试图使用可变性注解来改变类型检查行为;这不是它们的用途。

可能会发现临时的可变性注解在“类型调试”情况下很有用,因为可变性注解会被检查。如果标注的可变性明显错误,TypeScript 将发出错误。

ts
// Error, this interface is definitely contravariant on T
interface Foo<out T> {
consume: (arg: T) => void;
}

然而,允许可变性注解更严格(例如,如果实际可变性是协变的,则 in out 是有效的)。确保在调试完成后删除你的可变性注解。

最后,如果你试图最大限度地提高类型检查性能,并且运行了分析器,并且确定了特定的类型很慢,并且确定了可变性推断特别慢,并且仔细验证了你要编写的可变性注解,你可能会在极其复杂的类型中通过添加可变性注解获得小幅的性能提升。

不要试图使用可变性注解来改变类型检查行为;这不是它们的用途。

TypeScript 文档是一个开源项目。通过发送 Pull Request 帮助我们改进这些页面 ❤

此页面的贡献者
OTOrta Therox (26)
NKNavneet Karnani (2)
DGMDr. Galambos Máté (1)
SPSantiago Palladino (1)
PPanosMagic32 (1)
12+

最后更新:2026 年 3 月 27 日