跳到主要内容

TS 泛型

示例代码:typescript-demos

泛型的重要性

“泛型是 TypeScript 中非常重要的一个概念,因为在之后实际开发中任何时候都离不开泛型的帮助,原因就在于泛型给予开发者创造灵活、可重用代码的能力。”1

“泛型是 TypeScript(以下简称 TS) 比较高级的功能之一,理解起来也比较困难。泛型应用场景非常广泛,很多地方都能看到它的影子。平时我们阅读开源 TS 项目源码,或者在自己的 TS 项目中使用一些第三方库(比如 React)的时候,经常会看到各种泛型定义。如果你不是特别了解泛型,那么你很可能不仅不会用,不会实现,甚至看不懂这是在干什么。2

泛型是什么

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

适用场景

两个参考标准

  1. 当你的函数、接口或类将处理多种数据类型时
  2. 当函数、接口或类在多个地方使用该数据类型时

示例 1

首先我们来定义一个 通用identity 函数,接收一个参数并直接返回它:

function identity(value) {
return value;
}

假如设定参数 valuenumber 类型,那么当传 string 就没用了。那很简单,改为 any 类型不就行了?这样当然也不行:

在静态编写的时候并不确定传入的参数到底是什么类型,只有当在运行时传入参数我们才能确定,这就导致了:

  1. 失去了定义应该返回哪种类型的能力
  2. 编译器失去了类型保护的作用

那么我们就需要变量,这个变量代表了传入的类型,然后再返回这个变量,它是一种特殊的变量,只用于表示类型而不是值。这个类型变量在 TS 中就叫做 泛型

function returnItem<T>(params: T): T {
return params;
}

在函数名称后面声明泛型变量 <T>,它用于捕获开发者传入的参数类型,然后就可以使用 T 作为参数类型和返回值类型。

所以,上述问题的解决方法就是使用泛型,写法如下:

function identity <T>(value: T): T {
return value;
}
console.log(identity(123)); // OK
console.log(identity('abc')); // OK

VSC 光标悬停调用函数名称,效果如下:

当我们调用 identity<number>(123) 时,number 类型就会像参数一样,在出现 T 的任何位置填充该类型。

identity-num

identity-str

说明

  • <T> 中的 T 被称为 类型变量,是我们希望传递给函数的类型占位符
  • T 代表 Type,在定义泛型时通常用作第一个类型变量名称
  • T 可以用任何有效名称代替,常见泛型变量代表还有:
    • K(Key):表示对象中的键类型
    • V(Value):表示对象中的值类型
    • E(Element):表示元素类型

示例 2

如何引入希望定义的任何数量的类型变量?

例如引入一个新的类型变量 U,用于扩展我们定义的 identity 函数:

function identity<T, U>(value: T, message: U): T {
console.log(message);
return value;
}

console.log(identity<number, string>(68, "Tony")); // OK

注意到上面返回类型只有 T,那么如果要返回两种类型呢?——使用 元组

function identity<T, U>(value: T, message: U): [T, U] {
console.log(message);
return [value, message];
}

console.log(identity<number, string>(68, "Tony")); // OK

示例 3

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

swap([7, 'seven']); // => ['seven', 7]

除了元组还有其他更好的方案?——使用 泛型接口

泛型接口

示例

// 定义一个用于 `identity` 函数的通用接口 `Identities`
interface Identities<V, M> {
value: V,
message: M
}

function identity<T, U> (value: T, message: U): Identities<T, U> {
console.log(value + ': ' + typeof(value));
console.log(message + ': ' + typeof(message));
let identities: Identities<T, U> = {
value,
message
}
return identities;
}

console.log(identity(68, 'Tony')); // OK

泛型类

泛型除了可以应用在函数和接口外,也可以应用在类中,还可以作用于类的成员函数。

在类中使用泛型,只需在类名后面,使用 <T, ...> 的语法定义任意多个类型变量:

interface GenericInterface<U> {
value: U,
getIdentity: () => U
}

// `IdentityClass` 实现 `GenericInterface<T>`,
// 当 `T` 表示 `number` 类型时,等于 `IdentityClass` 实现了 `GenericInterface<number>`
class IdentityClass<T> implements GenericInterface<T> {
value: T;

constructor(value: T) {
this.value = value;
}

getIdentity(): T {
return this.value;
}
}

const myNumberClass = new IdentityClass<number>(68);
console.log(myNumberClass.getIdentity()); // => 68

const myStringClass = new IdentityClass<string>('Tony');
console.log(myStringClass.getIdentity()); // => Tony

泛型约束与类型索引

泛型约束

泛型约束作用:限制每个类型变量接受的类型数量。

两种常见场景

  1. 确保属性存在
  2. 检查对象上的键是否存在

确保属性存在

例如,当我们处理字符串或数组时,假设 length 属性可用:

function identity<T>(arg: T): T {
console.log(arg.length); // Error, 类型“T”上不存在属性“length”
return arg;
}

这种情况下,编译器不知道 T 是否包含 length 属性,因为我们可以把任何类型赋值给类型变量 T。那么该如何做才能让 TS 编译器相信 Tlength 属性呢?有以下两种处理方式:

方式 1 - 继承属性接口类型

interface Length {
length: number;
}

// 让 `T` 实现继承接口类型 `Length`
function identity<T extends Length>(arg: T): T {
console.log(arg.length);
return arg;
}

// identity(68); // Error, 类型“number”的参数不能赋给类型“Length”的参数。
identity('string'); // OK
identity([1, 2, 3]); // OK

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

方式 2 - 显式设置特定类型

function identity<T>(arg: T[]): T[] {
console.log(arg.length);
return arg;
}

// or
function identity<T>(arg: Array<T>): Array<T> {
console.log(arg.length);
return arg;
}

// identity(68); // Error, 类型“number”的参数不能赋给类型“unknown[]”的参数。
identity([1, 2, 3]);

检查对象上的键是否存在

TS v2.1 引入一个 keyof 操作符,该操作符可以用于获取某种类型的所有键,其返回类型是联合类型

interface Person {
name: string,
age: number,
location: string,
}

type K1 = keyof Person; // `name` | `age` | `lacation`
type K2 = keyof Person[]; // `name` | `legnth` | `push` | `concat` | ...
type K3 = keyof { [x: string]: Person }; // `string` | `number`

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

使用示例

enum Difficulty {
Easy,
Intermediate,
Hard
}

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

let tsInfo = {
name: 'TS',
superSetOf: 'JS',
difficulty: Difficulty.Intermediate
}

// 获取 `tsInof` 的 `difficulty` 属性值
let difficulty: Difficulty = getProperty(tsInfo, 'difficulty'); // OK

// 获取 `tsInof` 的 `superSetOf` 属性值
// let superSetOf: string = getProperty(tsInfo, '_superSetOf'); // Error

VSCode 悬停可以看到具体报错信息,类型错误捕获成功

getProperty

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

泛型索引

问题:设计一个函数,这个函数接收两个参数,一个参数为对象,另一个参数为对象上的属性,通过这两个参数返回这个属性的值

function getValue(obj: object, key: string) {
return obj[key]; // Error, 类型为 "string" 的表达式不能用于索引类型 "{}"
}

TS 编译器告诉我们,参数 obj 实际上是 {},因此后面的 key 是无法在上面取得任何值,那么我们可以 extends 一个对象类型(T extends object):

function getValue<T extends object>(obj: T, key: string) {
return obj[key]; // Error, same tips
}

还是报错,因为 object 类型本质上还是 {},无法确定第二个参数 key 是不是存在于 obj 上,所以我们需要用 key 来进一步约束 obj

可以借助索引类型 <U extends keyof T>:用索引类型 keyof T 把传入的对象的属性类型取出生成一个联合类型,这里的泛型 U 就会被约束在这个联合类型中,这样一来函数就被完整定义了:

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

const person = { name: 'Tony', age: 18 };
getValue(person, 'age'); // OK

在这里,getValue 第二个参数 key 类型被约束到一个联合类型 name | id,他只能是这两个之一,至此你就能获取到良好的类型提示了:

generic-keyof

多重类型的泛型约束

上面只是单一类型对泛型进行约束,那么如果允许被两个接口类型约束呢?

interface FirstInterface {
dosomething(): number
}

interface SecondInterface {
dosomethingElse(): string
}

class Demo<T extends FirstInterface, SecondInterface> {
private genericProperty!: T;

useT() {
this.genericProperty.dosomething(); // OK
// this.genericProperty.dosomethingElse(); // Error, 类型“T”上不存在属性“dosomethingElse”
}
}

如上所示,只有 FirstInterface 约束了泛型 TSecondInterface 并没有生效,那么怎么用两个接口 同时约束 泛型呢?

如果这样呢?

class Demo<T extends FirstInterface, T extends SecondInterface> {   // Error, 标识符“T”重复。
// ...
}

也不行。

既然泛型“只能被一个接口类型约束”,那么我们可以弄个子接口类型 childInterface,然后继承 FirstInterfaceSecondInterface,不就可以吗?

interface FirstInterface { dosomething(): number };
interface SecondInterface { dosomethingElse(): string };
interface childInterface extends FirstInterface, SecondInterface {}; // here

class Demo<T extends childInterface> {
private genericProperty!: T;

useT() {
this.genericProperty.dosomething(); // OK
this.genericProperty.dosomethingElse(); // OK
}
}

成功了。实际上,我们也可以利用交叉类型来进行 多类型约束

interface FirstInterface { dosomething(): number };
interface SecondInterface { dosomethingElse(): string };

class Demo<T extends FirstInterface & SecondInterface> { // here
private genericProperty!: T;

useT() {
this.genericProperty.dosomething(); // OK
this.genericProperty.dosomethingElse(); // OK
}
}

泛型参数默认类型

TS v2.3 以后,泛型的类型参数也可以指定默认类型,语法:<T=Default Type>

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

示例

interface A<T = string> { name: T } // 泛型接口A默认为 `string` 类型

const strA: A = { name: 'Tony' } // OK

const numB: A<number> = { name: 1000 } // OK

泛型条件类型

TS v2.8 引入了条件类型,可以根据某些条件(类型兼容性约束)得到不同的类型。

示例 1

条件类型会以一个条件表达式进行类型关系检测,从而在两种类型中选择其一

// 若 `T` 能够赋值给 `U`,那么类型是 `X`,否则为 `Y`
T extends U ? X : Y

示例 2

在条件类型表达式中,通常还会结合 infer 关键字,实现类型抽取

// 定义一个泛型接口Dictionary;T默认为类型为any;键为string,值为T
interface Dictionary<T = any> {
[key: string]: T
}

// 用类型别名 `type` 并基于 `Dictionary` 弄了一个新类型,名为 `StrDict`
type StrDict = Dictionary<string>;

// ???
type DictMember<T> = T extends Dictionary<infer V> ? V : never;
// ???
type StrDictMember = DictMember<StrDict>;

示例 3

利用条件类型和 infer 关键字,可以方便地实现获取 Promise 对象的返回值类型

interface Person {
name: string,
age: number
}

async function stringPromise() {
return 'Hello, Tony';
}

async function personPromise() {
return { name: 'Tony', age: 30 } as Person;
}

type PromiseType<T> = (args: any[]) => Promise<T>;
type UnPromiseify<T> = T extends PromiseType<infer U> ? U : never; // `infer` ???

type extractStringPromise = UnPromiseify<typeof stringPromise>;
type extractPersonPromise = UnPromiseify<typeof personPromise>;

泛型工具类

为了方便开发者 TypeScript 内置了一些常用的工具类型,比如 PartialRequiredReadonlyRecordReturnType 等。

Partial

定义

// node_modules/typescript/lib/lib.es5.d.ts

/**
* Make all properties in T optional
* 将某个类型里的属性全部变为可选项 `?`
*/
type Partial<T> = {
[P in keyof T]?: T[P];
};

示例

interface Todo {
title: string,
description: string
}

function updateTodo(todo: Todo, fieldsToUpdate: Partial<Todo>) {
return { ...todo, ...fieldsToUpdate };
}

const todo1 = {
title: 'organize desk',
description: 'clear clutter'
}

const todo2 = updateTodo(todo1, {
description: 'throw out trash'
})

console.log(todo1); // => {title: "organize desk", description: "clear clutter"}
console.log(todo2); // => {title: "organize desk", description: "throw out trash"}

说明

Record

定义

// node_modules/typescript/lib/lib.es5.d.ts

/**
* Construct a type with a set of properties K of type T
* 将 `K` 中所有的属性的值转化为 `T` 类型
*/
type Record<K extends keyof any, T> = {
[P in K]: T;
};

示例

interface PageInfo {
title: string,
}

type Page = `home` | `about` | `concact`;

const x: Record<Page, PageInfo> = {
about: { title: 'about' },
concact: { title: 'concact' },
home: { title: 'home' }
}

Pick

定义

// node_modules/typescript/lib/lib.es5.d.ts

/**
* From T, pick a set of properties whose keys are in the union K
* 将某个类型中的子属性挑出来,变成包含这个类型部分属性的子类型
*/
type Pick<T, K extends keyof T> = {
[P in K]: T[P];
};

示例

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

type TodoPreview = Pick<Todo, 'title' | 'completed'>;

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

Exclude

定义

// node_modules/typescript/lib/lib.es5.d.ts

/**
* Exclude from T those types that are assignable to U
* 将某个类型中属于另一个的类型移除掉
*/
type Exclude<T, U> = T extends U ? never : T;

示例

type T0 = Exclude<'a' | 'b', 'a'>;  // 'b' | 'c'
type T1 = Exclude<'a' | 'b' | 'c', 'a' | 'b'>; // 'c
type T2 = Exclude<string | number | (() => void), Function>; // string | number

ReturnType

定义

// node_modules/typescript/lib/lib.es5.d.ts

/**
* Obtain the return type of a function type
* 获取函数 `T` 的返回类型
*/
type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;

示例

type T0 = ReturnType<() => string>; // string
type T1 = ReturnType<(s: string) => void>; //void
type T2 = ReturnType<<T>() => T>; // {};???
type T3 = ReturnType<<T extends U, U extends number[]>() => T>; // number[];障眼法?
type T4 = ReturnType<any>; // any
type T5 = ReturnType<never>; // any
// type T6 = ReturnType<string>; // Error: 类型“string”不满足约束“(...args: any) => any”。
// type T7 = ReturnType<Function>; // Error: 类型“Function”不满足约束“(...args: any) => any”

想了解更多,可以参考以下资料:

  1. piotrwitek/utility-types(源码库)
  2. TypeScript 3.7 Utility Types | Printable PDF Cheat Sheet
  3. 掌握 TS 这些工具类型,让你开发事半功倍
  4. TypeScript 强大的类型别名
  5. Axes Blog

使用泛型创建对象

问题:

  1. 如何使用泛型来创建对象?
  2. 什么情况下需要使用泛型来创建对象?

问题

示例

class FirstClass {
id: number | undefined;
}

class SecondClass {
name: string | undefined;
}

class GenericCreator<T> {
create(): T {
return new T(); // Error: “T”仅表示类型,但在此处却作为值使用。
}
}

const creator1 = new GenericCreator<FirstClass>();
const firstClass: FirstClass = creator1.create();

const creator2 = new GenericCreator<SecondClass>();
const secondClass: SecondClass = creator2.create();

问题

  1. 这段代码无法运行,因为报错了
  2. 为了使通用类能够创建 T 类型的对象,需要通过其构造函数来引用 T 类型,那么该如何实现?

要解决这个问题,需要先了解:

  1. 构造函数签名
  2. 构造函数类型

构造函数签名

在 TS 接口中,可以使用 new 关键字来描述一个构造函数

interface Point {
new (x: number, y: number): Point; // 构造签名
}

构造签名语法

// new 可选类型参数 可选参数列表 可选类型注解
new TypeParametersopt ( ParameterListopt ) TypeAnnotationopt

常见形式

new C
new C ( ... )
new C < ... > ( ... )

构造函数类型

描述

  1. 包含一个或多个构造签名的对象类型被称为构造函数类型
  2. 构造函数类型可以使用 构造函数类型字面量包含构造签名的对象类型字面量 来编写

构造函数类型字面量形式

new < T1, T2, ... > ( p1, p2, ... ) => R

包含构造签名的对象类型字面量

{ new < T1, T2, ... >( p1, p2, ... ) : R }

示例

new (x: number, y: number) => Point // 构造函数类型字面量形式

// 等价于
{ new (x: number, y: number): Point } // 包含构造签名的对象类型字面量

构造函数类型的应用

错误示例

interface Point {
new (x: number, y: number): Point,
x: number,
y: number
}

class Point2D implements Point { // Error: 类“Point2D”错误实现接口“Point”
readonly x: number;
readonly y: number;

constructor(x: number, y: number) {
this.x = x;
this.y = y;
}
}

const point: Point = new Point2D(1, 2);

问题

  1. Point2D 错误实现接口 Point
  2. 需要把前面定义的 Point 接口中的属性和构造函数类型进行分离

正确示例

interface Point {
x: number,
y: number
}

interface PointConstructor {
new (x: number, y: number): Point
}

class Point2D implements Point {
readonly x: number;
readonly y: number;

constructor(x: number, y: number) {
this.x = x;
this.y = y;
}
}

// 工厂函数
function newPoint(
pointConstructor: PointConstructor,
x: number,
y: number
): Point {
return new pointConstructor(x, y);
}

const point: Point = newPoint(Point2D, 1, 2);

使用泛型创建对象

了解完 构造函数签名构造函数类型 后,就可以来解决开头的问题

class FirstClass {
id: number | undefined;
}

class SecondClass {
name: string | undefined;
}

class GenericCreator<T> {
// 重构 `create` 方法
create<T>(c: { new (): T }): T {
return new c();
}
}

const creator1 = new GenericCreator<FirstClass>();
const firstClass: FirstClass = creator1.create(FirstClass);

const creator2 = new GenericCreator<SecondClass>();
const secondClass: SecondClass = creator2.create(SecondClass);

想了解更多,可以参考

Footnotes

  1. 深入浅出TypeScript:从基础知识到类型编程

  2. 你不知道的 TypeScript 泛型

  3. 一文读懂 TypeScript 泛型及应用( 7.8K字)