TypeScript - 泛型
# 1. 泛型介绍
在软件工程中,我们不仅要创建定义良好且一致的 API,还要考虑代码的可重用性。组件不仅能够支持当前的数据类型,同时也能支持未来的数据类型,这在创建大型系统时为你提供了灵活性。
在 C# 和 Java 等语言中,可以使用泛型来创建可重用的组件。一个组件可以支持多种类型的数据,这样用户就可以以自己的数据类型来使用组件。
# 2. 基础示例
下面来创建第一个使用泛型的例子:identity
函数。这个函数会返回任何传入它的值。可以把这个函数当成是 echo
命令。
如果不用泛型的话,这个函数可能是下面这样:
// identity 函数,返回传入的数字
function identity(arg: number): number {
return arg;
}
2
3
4
或者,我们可以使用 any
类型来定义函数:
// identity 函数,返回传入的任何类型的值
function identity(arg: any): any {
return arg;
}
2
3
4
使用 any
类型会导致这个函数可以接收任何类型的 arg
参数,但是这样就丢失了一些信息:传入的类型与返回的类型应该是相同的。如果传入一个数字,返回值可以是任何类型。
因此,需要一种方法使返回值的类型与传入参数的类型相同。这里,我们使用了 类型变量,它是一种特殊的变量,只用于表示类型而不是值。
// 泛型函数 identity,返回与传入参数类型相同的值
function identity<T>(arg: T): T {
return arg;
}
2
3
4
在这个函数中,添加了类型变量 T
。T
帮助我们捕获用户传入的类型(例如:number
),之后我们可以使用这个类型。这里再次使用了 T
作为返回值类型。现在可以知道参数类型与返回值类型是相同的了。这允许我们跟踪函数里使用的类型信息。
这个版本的 identity
函数称为泛型,因为它可以适用于多个类型。不同于使用 any
,它不会丢失信息,像第一个例子那样保持准确性,传入数值类型并返回数值类型。
定义了泛型函数后,可以用两种方法使用。第一种是传入所有的参数,包含类型参数:
// 显式指定泛型参数为 string 类型
let output = identity<string>('myString');
2
这里明确指定了 T
是 string
类型,并作为一个参数传给函数,使用了 <>
括起来而不是 ()
。
第二种方法更常见。利用了 类型推论:即编译器会根据传入的参数自动地帮助我们确定 T
的类型:
// 利用类型推论
let output = identity('myString');
2
注意没必要使用尖括号(<>
)来明确地传入类型;编译器可以查看 myString
的值,然后把 T
设置为它的类型。类型推论帮助我们保持代码精简和高可读性。如果编译器不能够自动地推断出类型,就需要像上面那样明确地传入 T
的类型,在一些复杂的情况下,这是可能出现的。
# 3. 使用泛型变量
使用泛型创建像 identity
这样的泛型函数时,编译器要求在函数体中必须正确地使用这个通用的类型。换句话说,必须把这些参数当作是任意或所有类型。
来看之前 identity
的例子:
// 泛型函数 identity
function identity<T>(arg: T): T {
return arg;
}
2
3
4
如果想打印出 arg
的长度,可以这样做:
// 打印传入参数的长度
function loggingIdentity<T>(arg: T): T {
console.log(arg.length); // 错误: T 可能没有 length 属性
return arg;
}
2
3
4
5
如果这么做,编译器会报错,因为使用了 arg
的 .length
属性,但没有指明 arg
具有这个属性。记住,这些类型变量代表的是任意类型,所以使用这个函数的人可能传入的是一个数字,而数字是没有 .length
属性的。
现在假设我们想操作 T
类型的数组而不直接是 T
。由于我们操作的是数组,所以 .length
属性是应该存在的。我们可以像创建其它数组一样创建这个数组:
// 泛型函数 loggingIdentity,打印传入数组的长度
function loggingIdentity<T>(arg: T[]): T[] {
console.log(arg.length); // 正确
return arg;
}
2
3
4
5
可以这样理解 loggingIdentity
的类型:泛型函数 loggingIdentity
,接收类型参数 T
和参数 arg
,它是个元素类型是 T
的数组,并返回元素类型是 T
的数组。如果传入数字数组,将返回一个数字数组,因为此时 T
的类型为 number
。这可以让我们把泛型变量 T
当作类型的一部分使用,而不是整个类型,增加了灵活性。
# 4. 泛型类型
在前面的例子中,我们创建了一个通用的 identity
函数,该函数适用于不同的类型。现在,我们研究一下函数本身的类型,以及如何创建泛型接口。
# 泛型函数的类型
泛型函数的类型与非泛型函数的类型基本相同,只是有一个类型参数在最前面,类似于函数声明:
// 定义一个泛型函数 identity
function identity<T>(arg: T): T {
return arg;
}
// 定义一个函数类型 myIdentity,使用泛型参数 <T>
let myIdentity: <T>(arg: T) => T = identity;
2
3
4
5
6
7
我们还可以使用不同的泛型参数名,只要在数量和使用方式上能对应上就可以:
// 使用不同的泛型参数名 <U>,但效果相同
let myIdentity: <U>(arg: U) => U = identity;
2
还可以使用带有调用签名的对象字面量来定义泛型函数:
// 使用对象字面量定义泛型函数类型
let myIdentity: {<T>(arg: T): T} = identity;
2
# 泛型接口
使用泛型接口可以更清晰地定义函数的类型。可以将上面的对象字面量提取出来作为一个接口:
// 定义一个泛型接口
interface GenericIdentityFn {
<T>(arg: T): T;
}
// 定义一个泛型函数 identity
function identity<T>(arg: T): T {
return arg;
}
// 使用泛型接口定义函数类型
let myIdentity: GenericIdentityFn = identity;
2
3
4
5
6
7
8
9
10
11
12
还可以把泛型参数当作整个接口的一个参数。这样接口里的其它成员也能知道这个参数的类型了:
// 定义一个泛型接口,接口本身接收泛型参数
interface GenericIdentityFn<T> {
(arg: T): T;
}
// 定义一个泛型函数 identity
function identity<T>(arg: T): T {
return arg;
}
// 使用泛型接口定义函数类型,并指定泛型参数为 number
let myIdentity: GenericIdentityFn<number> = identity;
2
3
4
5
6
7
8
9
10
11
12
注意,这样的接口定义了一种约定,使得函数在调用时必须使用相同的泛型参数类型。
除了泛型接口,还可以创建泛型类。注意,无法创建泛型枚举和泛型命名空间。
# 5. 泛型类
泛型类与泛型接口类似。泛型类使用尖括号 (<>
) 括起泛型类型,跟在类名后面。
// 定义一个泛型类 GenericNumber
class GenericNumber<T> {
zeroValue: T;
add: (x: T, y: T) => T;
}
// 创建一个 GenericNumber 的实例,泛型参数为 number
let myGenericNumber = new GenericNumber<number>();
myGenericNumber.zeroValue = 0;
myGenericNumber.add = function(x, y) {
return x + y;
};
2
3
4
5
6
7
8
9
10
11
12
GenericNumber
类的使用是十分直观的,并且没有限制它只能使用 number
类型。也可以使用字符串或其它更复杂的类型。
// 创建一个 GenericNumber 的实例,泛型参数为 string
let stringNumeric = new GenericNumber<string>();
stringNumeric.zeroValue = '';
stringNumeric.add = function(x, y) {
return x + y;
};
console.log(stringNumeric.add(stringNumeric.zeroValue, 'test')); // 输出 'test'
2
3
4
5
6
7
8
与接口一样,直接把泛型类型放在类后面,可以帮助我们确认类的所有属性都在使用相同的类型。
在 TypeScript 的类中,类有两部分:静态部分和实例部分。泛型类指的是实例部分的类型,所以类的静态属性不能使用这个泛型类型。
# 6. 泛型约束
有时候,我们想要操作某类型的一组值,并且我们知道这组值具有什么样的属性。在 loggingIdentity
例子中,我们想访问 arg
的 length
属性,但是编译器并不能证明每种类型都有 length
属性,所以就报错了。
// 试图访问泛型参数的 length 属性,会导致编译错误
function loggingIdentity<T>(arg: T): T {
console.log(arg.length); // 错误: T 可能没有 length 属性
return arg;
}
2
3
4
5
相比于操作 any
所有类型,我们想要限制函数去处理任意带有 .length
属性的所有类型。只要传入的类型有这个属性,我们就允许。为此,我们需要列出对于 T
的约束要求。
# 使用接口约束泛型
可以定义一个接口来描述约束条件,创建一个包含 .length
属性的接口,并使用这个接口和 extends
关键字来实现约束:
// 定义一个接口,描述具有 length 属性的类型
interface Lengthwise {
length: number;
}
// 使用接口约束泛型 T,要求 T 具有 length 属性
function loggingIdentity<T extends Lengthwise>(arg: T): T {
console.log(arg.length); // 正确
return arg;
}
// 错误,因为 3 是数字,不具有 length 属性
loggingIdentity(3);
// 正确,传入的对象具有 length 属性
loggingIdentity({length: 10, value: 3});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 在泛型约束中使用类型参数
可以声明一个类型参数,并让它被另一个类型参数所约束。例如,现在想要用属性名从对象里获取这个属性,并且确保这个属性存在于对象 obj
上,因此需要在这两个类型之间使用约束。
// 使用两个类型参数 T 和 K,其中 K 被约束为 T 的键类型
function getProperty<T, K extends keyof T>(obj: T, key: K) {
return obj[key];
}
let x = {a: 1, b: 2, c: 3, d: 4};
// 正确,因为 'a' 是 x 的键
getProperty(x, 'a');
// 错误,因为 'm' 不是 x 的键
getProperty(x, 'm');
2
3
4
5
6
7
8
9
10
11
12
在这个例子中,keyof T
表示 T
类型的所有属性名构成的联合类型。通过这种方式,可以在泛型约束中使用类型参数来实现更精确的类型检查。
# 7. 泛型工具类型
TypeScript 提供了一些内置的泛型工具类型,用于对类型进行转换和操作。这些工具类型可以帮助我们简化代码,并在类型级别上进行更精细的控制。
# Partial
Partial<T>
的作用是将某个类型 T
中的所有属性变为可选属性。这在处理不完整的数据对象时非常有用。
示例:
interface Person {
name: string;
age: number;
}
// 使用 Partial 将 Person 类型的属性全部变为可选
function student<T extends Person>(arg: Partial<T>): Partial<T> {
return arg;
}
const studentInfo: Partial<Person> = {
name: 'John'
// age 属性可以不存在
}
2
3
4
5
6
7
8
9
10
11
12
13
14
在上面的示例中,student
函数接收一个 Partial<T>
类型的参数,意味着传入的对象可以只包含 Person
类型的一部分属性。
# Record
Record<K extends keyof any, T>
的作用是将键类型 K
中的所有属性转换为类型 T
。这是创建一个映射类型的快捷方式。
示例:
interface PageInfo {
title: string;
}
// 使用 Record 创建一个映射类型,将 Page 的键映射到 PageInfo 类型
type Page = 'home' | 'about' | 'other';
const x: Record<Page, PageInfo> = {
home: { title: "Home Page" },
about: { title: "About Page" },
other: { title: "Other Page" },
};
2
3
4
5
6
7
8
9
10
11
12
在上面的示例中,Record<Page, PageInfo>
表示一个对象,其中的每个键(Page
类型的值)都映射到一个 PageInfo
对象。
# Pick
Pick<T, K extends keyof T>
的作用是从类型 T
中挑选出某些属性,生成一个新的类型。这对于创建类型的子集非常有用。
示例:
interface Todo {
title: string;
description: string;
time: string;
}
// 使用 Pick 从 Todo 类型中挑选出 title 和 time 属性
type TodoPreview = Pick<Todo, 'title' | 'time'>;
const todo: TodoPreview = {
title: '吃饭',
time: '明天'
};
2
3
4
5
6
7
8
9
10
11
12
13
在上面的示例中,TodoPreview
类型仅包含 Todo
类型的 title
和 time
属性。
# Exclude
Exclude<T, U>
的作用是从类型 T
中排除那些可以分配给类型 U
的属性。它用于从联合类型中去除某些成员。
示例:
// 使用 Exclude 从 "a" | "b" | "c" 中排除 "a"
type T0 = Exclude<"a" | "b" | "c", "a">;
const t: T0 = 'b'; // T0 的类型是 "b" | "c"
2
3
4
在上面的示例中,Exclude<"a" | "b" | "c", "a">
的结果是 b | c
,因为 a
被排除掉了。
# ReturnType
ReturnType<T>
的作用是用于获取函数 T
的返回类型。这在需要动态获取函数返回类型时非常有用。
示例:
// 定义各种返回类型的函数
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
// 错误示例:以下两行会报错,因为 ReturnType 需要一个函数类型
// type T6 = ReturnType<string>; // Error
// type T7 = ReturnType<Function>; // Error
2
3
4
5
6
7
8
9
10
11
在上面的示例中,ReturnType
用于获取不同函数的返回类型。需要注意的是,ReturnType
只能用于函数类型,而不能用于普通的类型。
这些泛型工具类型是 TypeScript 提供的强大功能,可以帮助我们更好地处理复杂的类型操作和转换。它们极大地提高了代码的灵活性和可维护性。