TypeScript入门到精通——泛型
泛型
泛型程序设计是一种编程风格或编程范式,它允许在程序中定义形式类型参数,然后在泛型实例化时使用实际类型参数来替换形式类型参数。通过泛型,我们能够定义通用的数据结构或类型,这些数据结构或类型仅在它们操作的实际类型上有差别。泛型程序设计是实现可重用组件的一种手段。
一、泛型简介
首先,我们定义一个非泛型版本的identity函数。我们将identity函数的参数类型和返回值类型都定义为number类型。如下所示:
function identity(arg: number): number { return arg; }
在这个示例中,arg
参数的类型被定义为number
,而返回值的类型也是number
。函数体中的return
语句直接返回传入的参数arg
。
使用这个函数时,你需要传入一个number
类型的值作为参数,并且会得到一个number
类型的返回值。例如:
const result = identity(42); // 正确的调用方式 console.log(result); // 输出: 42
如果你尝试使用其他类型的参数调用这个函数,TypeScript编译器会报错。例如,以下的调用方式会导致编译错误:
const result = identity("hello"); // 错误的调用方式,编译错误 console.log(result);
在这个错误的调用方式中,我们尝试将一个string
类型的值作为参数传递给identity
函数,这会导致类型不匹配的错误。
如果想让 identity 函数能够接受任意类型的参数,那么就需要使用顶端类型。例如,下例中我们将 identity 函数的参数类型和返回值类型都声明为 unknown 类型,这样它就可以同时处理 number 类型、 string 类型以及对象类型等的值,示例如下:
function identity(arg: unknown): unknown { return arg; } identity(0); identity(‘foo’); identity({x:0, y:0});
在这个示例中,arg
参数的类型是unknown
,这意味着它可以接受任何类型的值作为参数。同样,返回值的类型也是unknown
,表示它可以返回任何类型的值。
虽然 any 类型或 unknown 类型能够让 identiy 函数变得通用,使其能够接受任意类型的参数,但是却失去了参数类型与返回值类型相同这个重要信息。从 identity 函数声明中我们只能了解到该函数接受任意类型的参数并返回任意类型的值,参数类型与返回值类型之间并无联系。那么,需要有一种放肆让我们既能够捕获传入参数的类型,又能够使用捕获参数,还能够保证参数类型与返回值类型是一致的。
接下来,我们尝试给identity函数添加一个类型参数。示例如下:
function identity<T>(arg: T): T { return arg; }
这段 TypeScript 代码定义了一个名为identity
的泛型函数。让我们逐行解析它:
function identity<T>(arg: T): T {
: 这一行定义了一个名为identity
的泛型函数。<T>
表示这是一个泛型函数,其中T
是一个类型参数。这意味着你可以用任何类型来调用这个函数。arg: T
表示这个函数有一个参数,它的类型是T
。: T
表示这个函数的返回类型也是T
。return arg;
: 这一行是这个函数的主体。它只是简单地返回了传入的参数arg
。因为arg
的类型是T
,所以返回的类型也是T
。}
: 这一行表示函数定义的结束。
二、形式类型参数
2.1、形式类型参数声明
泛型类型参数能够表示绑定到泛型类型或泛型函数调用的某个实际类型。在类声明、接口声明、类型别名声明以及函数声明中都支持定义类型参数。泛型形式类型参数列表定义的具体语法如下所示:
<TypeParameter, TypeParameter, ...>
在该语法中,TypeParameter 表示形式类型参数名,形式类型参数置于 "<" 和 ">" 符号之间。当同时存在多个形式类型参数时,类型参数之间需要使用逗号 "," 进行分割。
形式类型参数名必须为合法的标识符。形式类型参数名通常以大写字母开头,因为它代表一个类型。在一些编程风格指南中,推荐给形式类型参数取一个具有描述性的名字,如 TResponse,同时还建议形式类型参数名以大写字母T(Type的首字母)作为前缀。另一种流程的命名方式是使用单个大写字母作为形式类型参数名。该风格的命名通常由字母 T 开始,并依次使用后续的 U、V 等大写字母。若形式类型参数列表中只存在一个或者少量的类型参数,可以考虑该风格,但前提是不能应用想成的可读性。
2.1.1、描述性命名风格
function processData<TInput, TOutput>(input: TInput, output: TOutput): TOutput { // 实现代码... }
这段代码是一个函数声明,它使用了 TypeScript 的泛型语法。下面是关于这段代码的详细解释:
- function processData<TInput, TOutput>(input: TInput, output: TOutput): TOutput { ... } 这是函数的声明。processData 是函数的名称,它是一个泛型函数,也就是说,它可以处理各种类型的输入和输出。
- TInput 和 TOutput 是形式类型参数。这意味着在函数的实际调用中,你可以使用具体的类型替换TInput和TOutput。例如,如果你调用 processData<string, number>(input, output),那么 TInput 被替换为 string,TOutput 被替换为 number。
- input: TInput 和 output: TOutput 是参数列表。这意味着函数接受两个参数,input 和 output,它们的类型分别是 TInput 和 TOutput。
- TOutput 是返回值的类型。这意味着该函数将返回一个类型为 TOutput 的值。
- 在函数体中,你可以根据需要对输入(input)进行处理,并返回处理后的结果,其类型应与 TOutput 匹配。
例如,你可以这样调用这个函数:
let result = processData<string, number>("hello", 5);
2.1.2、使用单个大写字母作为形式类型参数名的风格
function combine<T1, T2, T3>(arg1: T1, arg2: T2, arg3: T3): T1 | T2 | T3 { // 实现代码... }
这段 TypeScript 代码定义了一个名为 combine 的泛型函数,该函数接受三个参数 arg1、arg2 和 arg3,它们的类型都是泛型类型参数 T1、T2 和 T3。函数的返回类型是这三个类型参数的联合类型,也就是说,返回值的类型可以是 T1、T2 或 T3 中的任意一个。
下面是对代码的详细解释:
-
-
-
- function combine<T1, T2, T3>(arg1: T1, arg2: T2, arg3: T3): T1 | T2 | T3 { ... } 这一行定义了一个泛型函数 combine,该函数具有三个类型参数 T1、T2 和 T3,分别对应三个参数 arg1、arg2 和 arg3。
- 同时,该函数声明返回一个类型为 T1 | T2 | T3 的值,也就是说,返回值的类型可以是 T1、T2 或 T3 中的任意一个。
- 在函数体中,你需要根据实际需求编写函数逻辑。由于函数返回值的类型是 T1 | T2 | T3,这意味着你可以在函数内部使用这三个类型的任意一个。
-
-
例如,如果你想返回这三个参数中的最大值,可以这样写:
function combine<T1 extends number, T2 extends number, T3 extends number>(arg1: T1, arg2: T2, arg3: T3): T1 | T2 | T3 { return Math.max(arg1, arg2, arg3); }
在这个例子中,我们限制了 T1、T2 和 T3 都必须是数字类型(通过使用 extends number 约束),然后使用 Math.max() 函数返回这三个参数中的最大值。注意,这里的返回值类型依然是 T1 | T2 | T3,因为返回的数值可能是这三个数字类型中的任意一个。
2.2、类型参数默认类型
在声明形式类型参数时,可以为类型参数设置一个默认类型,这类似于函数默认参数。类型参数默认类型的语法如下所示:
<T = DefaultType>
该语法中,T 为形式类型参数,DefaultType 为类型参数 T 的默认类型,两者之间使用等号连接。例如,下例中形式类型参数 T 的默认类型为 boolean类型:
<T = boolean>
类型参数的默认类型也可以引用形式类型参数列表中的其他类型参数,但是只能引用在当前类型参数定义的类型参数。例如,下例中类型参数 U 的默认类型为类型参数 T。因为类型参数 T 是在类型参数 U 之前定义的,所以正确的定义方法,如下所示:
<T, U = T>
2.3、可选的类型参数
如果一个形式类型参数没有定义默认类型,那么它是一个必选类型参数;反之如果一个形式类型参数定义了默认类型,那么它就是一个可选的类型参数。在形式类型参数列表中,必选类型参数不允许出现在可选类型参数之后。示例如下:
<T = boolean, U> //错误 <T, U = boolean> //正确 <T = U, U = boolean> //错误 <T = boolean, U = T> //正确
三、实际类型参数
当你使用泛型时,你可以传入一个实际类型参数。这个过程被称为泛型的实例化。当你传入实际类型参数时,你正在提供一个具体的类型来替代泛型类型。传入实际类型参数的语法如下所示:
<Type, Type, ...>
在该语法中,实际类型参数列表置于 "<" 和 ">" 符号之间;Type 表示一个实际类型参数,如 原始类型、接口类型等;多个实际类型参数之间使用逗号 "," 分割。
举个例子,假设你有一个函数,它接受一个键和一个值,然后返回一个对象,该对象的键是键,值是值:
function createObject<K, V>(key: K, value: V): { [key]: V } { return { [key]: value }; }
在这个例子中,K
和V
是类型参数。你可以使用实际类型来替换它们。比如,你可以创建一个字符串键和数字值的对象:
let obj = createObject<string, number>('age', 30);
在这个例子中,'age'
是K
的实际类型参数,30
是V
的实际类型参数。当你这样做时,TypeScript 会推断出K
的类型是string
,V
的类型是number
。
四、泛型约束
4.1、泛型约束声明
在泛型的形式类型参数上允许定义一个约束条件,它能够限定类型参数的实际类型的最大范围。我们将类型参数的约束条件称为泛型约束。定义泛型约束的语法如下所示:
<TypeParameter extends ConstraintType>
该语法中,TypeParameter 表示形式类型参数名;extends 是关键字;ConstaintType 表示一个类型,该类型用于约束 TypeParameter 的可选类型范围。
对于一个形式类型参数,可以同时定义泛型约束和默认类型,但默认类型必须满足泛型约束。如果泛型形式类型参数定义了泛型约束,那么传入的实际类型参数必须符合泛型约束,否则将产生错误 。
4.2、泛型约束引用类型参数
在泛型约束中,约束类型允许引用当前形式类型参数列表中的其他类型参数。例如下列中形式类型参数 U 引用了在其左侧定义的形式类型参数 T 作为约束类型:
<T, U extends T>
下例中,形式类型参数 T 引用了在其右侧定义的形式类型参数 U;
<T extends U,U>
需要注意的是,一个形式类型参数不允许直接或间接地将其自身作为约束类型,否则将产生循环引用的编译错误。
<T extends T> //错误 <T extends U, U extends T> //错误
4.3、基约束
本质上,每个类型参数都有一个基约束(Base Constraint),它与是否在形式类型参数上定义了泛型约束无关。类型参数的实际类型一定是其基约束的子类型。对于任意类型的参数 T,其基约束的计算规则有三个。
规则一,如果类型参数 T 声明了泛型约束,且泛型约束为另外一个类型参数 U,那么类型参数 T 的基约束为类型参数 U。
示例如下:
<T extends U> //类型参数T的基约束为类型参数U
规则二,如果类型参数 T 声明了泛型约束,且泛型约束为某一具体类型 Type,那么类型参数 T 的基约束为类型 Type。
示例如下:
<T extends boolean>
规则三,如果类型参数 T 没有声明泛型约束,那么类型参数 T 的基约束为空对象类型字面量 "{}"。除了 undefined 类型和 null 类型外,其他任何类型都可以赋值给空对象类型字面量。
示例如下:
<T>
五、泛型函数
若一个函数的函数签名中带有类型参数,那么它是一个泛型函数。泛型函数中的类型参数用来描述不同参数之间及参数和函数返回值之间的关系。泛型函数中的类型参数既可以用于形式参数的类型,也可以用于函数返回值类型。
在 TypeScript 中,泛型函数的主要诞生背景是为了解决在强类型编程中处理各种数据类型的问题。在没有泛型的情况下,如果我们要编写一个可以处理多种数据类型的函数,我们就需要为每种数据类型重写一遍函数,这显然是不现实的。
泛型函数允许我们编写灵活的代码,可以处理不同的数据类型,而无需为每种类型重写函数。通过使用类型参数,我们可以在函数签名中声明参数和返回值的类型,而无需具体指定它们是什么类型。这样,我们就可以在函数内部使用类型推断来处理参数和返回值,从而避免了手动指定类型的繁琐工作。
泛型函数解决了以下几个痛点:
- 代码重复:在没有泛型的情况下,为每种数据类型编写一个函数会导致大量的代码重复。使用泛型函数,我们可以编写一次函数,适用于所有数据类型,减少了代码的重复性。
- 类型安全:在强类型编程中,确保代码的类型安全非常重要。泛型函数允许我们声明参数和返回值的类型,从而在编译时检查类型是否正确,避免了运行时错误。
- 类型推断:泛型函数允许我们在函数内部使用类型推断来处理参数和返回值,这意味着我们不需要手动指定每个参数和返回值的类型。这使得编写代码更加简单和高效。
- 提高可读性:通过在函数签名中使用类型参数,可以清楚地看到函数期望的参数类型和返回值类型,提高了代码的可读性。这使得其他开发人员更容易理解函数的用途和参数的含义。
5.1、泛型函数的定义
泛型函数使用<T>
来标记类型参数,然后使用这个类型参数来声明函数的参数和返回值。
function identity<T>(arg: T): T {
return arg;
}
在上面的例子中,identity
是一个泛型函数,它可以接受任何类型的参数arg
,并返回与arg
相同类型的值。
5.2、泛型函数的示例
泛型函数在处理各种不同类型的参数时非常有用。例如,你可以使用泛型函数来创建一个函数,该函数可以处理数组和字符串。
function reverse<T extends string | Array<any>>(value: T): T { if (typeof value === 'string') { return value.split('').reverse().join(''); } else if (Array.isArray(value)) { return value.reverse(); } else { throw new Error('Input value is not string or array'); } }
在这个例子中,reverse
是一个泛型函数,它接受一个字符串或数组,并返回一个反转的类型。如果输入是字符串,它会将其分割为字符数组,然后反转并重新连接。如果输入是数组,它会直接反转数组。
5.3、泛型函数类型推断
TypeScript 可以自动推断泛型函数的类型。例如,如果你在调用identity
函数时传递一个数字,TypeScript 会自动推断T
是number
类型。
let result = identity(123); // Inferred type: number
5.4、泛型函数的注意事项
如果泛型函数的类型参数只在函数签名中出现了一次,该泛型函数是非必要的。因为 TypeScript 可以自动推断类型参数。例如,下面的函数是非必要的:
function unnecessary<T>(arg: T): T { // Unnecessary generic parameter return arg; // Type can be inferred from the argument }
在调用这个函数时,你可以直接传递一个参数,而不需要指定类型参数:
let result = unnecessary(123); // Type is inferred correctly as number
在类型参数声明<T>
之外,类型参数 T 只出现了一次,在这种情况下泛型函数也不是必需的。因为你可以直接指定类型参数。例如:
function unnecessary<T>(arg: T): string { // Unnecessary generic parameter T return arg.toString(); // Assume this works for all types T. It doesn't in reality. }
在调用这个函数时,你可以直接传递一个参数,并指定类型参数。
let result = unnecessary<number>(123); // Specify the type argument explicitly as number.
六、泛型接口
若接口的定义中带有类型参数,那么它是泛型接口。在泛型接口定义中,形式类型参数列表紧随着接口名之后。泛型接口定义的语法如下所示:
interface MyArray<T> extends Array<T> { first: T | undefined; last: T | undefined; }
七、泛型类型别名
7.1、泛型类型别名定义
在 TypeScript 中,泛型是一种在定义函数、接口或类时,不预先指定具体类型,而是在使用时再指定类型的特性。泛型提供了一种方式,让组件既可以支持当前的数据类型,也可以支持未来的数据类型。泛型的这种可重用性和灵活性对于创建大型系统非常重要。
泛型类型别名定义:
在 TypeScript 中,泛型可以通过类型别名来定义。例如,可以创建一个泛型类型别名,将一个对象中的键类型和值类型分别定义为 K 和 V:
type KeyValuePair<K, V> = { [key: K]: V };
这个类型别名定义了一个键类型为 K,值类型为 V 的对象。使用这个类型别名时,可以指定具体的 K 和 V 类型。例如,可以创建一个对象,其键类型为字符串,值类型为数字:
const age: KeyValuePair<string, number> = { 'age': 30 };
7.2、泛型类型别名示例
以下是一个使用泛型类型别名的示例。假设要创建一个函数,该函数接收一个对象数组,然后返回一个只包含特定键值对的对象数组:
function filterObjects<K, V>(objects: Array<{ [key: K]: V }>): Array<{ [key: K]: V }> { const result: Array<{ [key: K]: V }> = []; for (const obj of objects) { for (const key in obj) { if (obj.hasOwnProperty(key)) { result.push({ [key]: obj[key] }); } } } return result; }
在这个示例中,泛型参数 K 和 V 分别表示对象的键类型和值类型。使用这个函数时,可以指定具体的 K 和 V 类型:
const objects: Array<{ [key: string]: number }> = [{ 'name': 1 }, { 'age': 2 }, { 'city': 3 }]; const result = filterObjects<string, number>(objects); console.log(result); // [{ 'name': 1 }, { 'age': 2 }, { 'city': 3 }]
八、在函数中使用泛型
在函数中使用泛型,可以定义一个接受任意类型参数的函数。例如:
function fun1<T>(x: T): T {
return x;
}
fun1<string>('a'); // 传入string类型参数
fun1<number>(123); // 传入number类型参数
在 TypeScript 中,<T>(x: T): T 是函数的类型注解,表示这个函数接受一个类型为 T 的参数 x,并返回一个类型也为 T 的结果。这里的 T 是一个类型参数,在实际使用时会被具体的类型替换。
具体来说,function fun1<T>(x: T): T { return x; } 可以被解释为:
-
- fun1 是一个函数,它有一个类型参数 T。
- 这个函数接受一个参数 x,其类型是 T。
- 函数返回的结果类型也是 T。
- 函数的实现是简单地返回输入的参数 x。
九、在接口中使用泛型
在TypeScript中,接口可以定义泛型参数,以便在接口方法中使用。例如:
interface IInfo<T> { name: string; age?: number; say: (value: T) => void; } class Person implements IInfo<string> { name: string; age: number; constructor(name: string, age: number) { this.name = name; this.age = age; } say(value: string): void { console.log(`${this.name}: ${value}`); } }
在这个例子中,接口IInfo
定义了一个泛型参数T,并在say
方法中使用该参数。类Person
实现了IInfo<string>
接口,并定义了一个say
方法来输出信息。通过使用泛型,我们可以编写可重用的接口,适用于不同的类型需求。
十、在类中使用泛型
在TypeScript中,类也可以定义泛型参数,以便在类的实例成员中使用。例如:
class Box<T> { value: T; constructor(value: T) { this.value = value; } } let box1 = new Box<string>('a'); // 传入string类型参数 let box2 = new Box<number>(123); // 传入number类型参数
在这个例子中,类Box
定义了一个泛型参数T,并在构造函数中使用该参数。通过使用泛型,我们可以编写可重用的类,适用于不同的类型需求。