TypeScript 泛型
简介
有些时候,函数返回值的类型与参数类型是相关的。
function getFirst(arr) {
return arr[0];
}
上面示例中,函数getFirst()
总是返回参数数组的第一个成员。参数数组是什么类型,返回值就是什么类型。
这个函数的类型声明只能写成下面这样。
function f(arr:any[]):any {
return arr[0];
}
上面的类型声明,就反映不出参数与返回值之间的类型关系。
为了解决这个问题,TypeScript 就引入了“泛型”(generics)。泛型的特点就是带有“类型参数”(type parameter)。
function getFirst<T>(arr:T[]):T {
return arr[0];
}
上面示例中,函数getFirst()
的函数名后面尖括号的部分<T>
,就是类型参数,参数要放在一对尖括号(<>
)里面。本例只有一个类型参数T
,可以将其理解为类型声明需要的变量,需要在调用时传入具体的参数类型。
上例的函数getFirst()
的参数类型是T[]
,返回值类型是T
,就清楚地表示了两者之间的关系。比如,输入的参数类型是number[]
,那么 T 的值就是number
,因此返回值类型也是number
。
函数调用时,需要提供类型参数。
getFirst<number>([1, 2, 3])
上面示例中,调用函数getFirst()
时,需要在函数名后面使用尖括号,给出类型参数T
的值,本例是<number>
。
不过为了方便,函数调用时,往往省略不写类型参数的值,让 TypeScript 自己推断。
getFirst([1, 2, 3])
上面示例中,TypeScript 会从实际参数[1, 2, 3]
,推断出类型参数 T 的值为number
。
有些复杂的使用场景,TypeScript 可能推断不出类型参数的值,这时就必须显式给出了。
function comb<T>(arr1:T[], arr2:T[]):T[] {
return arr1.concat(arr2);
}
上面示例中,两个参数arr1
、arr2
和返回值都是同一个类型。如果不给出类型参数的值,下面的调用会报错。
comb([1, 2], ['a', 'b']) // 报错
上面示例会报错,TypeScript 认为两个参数不是同一个类型。但是,如果类型参数是一个联合类型,就不会报错。
comb<number|string>([1, 2], ['a', 'b']) // 正确
上面示例中,类型参数是一个联合类型,使得两个参数都符合类型参数,就不报错了。这种情况下,类型参数是不能省略不写的。
类型参数的名字,可以随便取,但是必须为合法的标识符。习惯上,类型参数的第一个字符往往采用大写字母。一般会使用T
(type 的第一个字母)作为类型参数的名字。如果有多个类型参数,则使用 T 后面的 U、V 等字母命名,各个参数之间使用逗号(“,”)分隔。
下面是多个类型参数的例子。
function map<T, U>(
arr:T[],
f:(arg:T) => U
):U[] {
return arr.map(f);
}
// 用法实例
map<string, number>(
['1', '2', '3'],
(n) => parseInt(n)
); // 返回 [1, 2, 3]
上面示例将数组的实例方法map()
改写成全局函数,它有两个类型参数T
和U
。含义是,原始数组的类型为T[]
,对该数组的每个成员执行一个处理函数f
,将类型T
转成类型U
,那么就会得到一个类型为U[]
的数组。
总之,泛型可以理解成一段类型逻辑,需要类型参数来表达。有了类型参数以后,可以在输入类型与输出类型之间,建立一一对应关系。
泛型的写法
泛型主要用在四个场合:函数、接口、类和别名。
函数的泛型写法
上一节提到,function
关键字定义的泛型函数,类型参数放在尖括号中,写在函数名后面。
function id<T>(arg:T):T {
return arg;
}
那么对于变量形式定义的函数,泛型有下面两种写法。
// 写法一
let myId:<T>(arg:T) => T = id;
// 写法二
let myId:{ <T>(arg:T): T } = id;
接口的泛型写法
interface 也可以采用泛型的写法。
interface Box<Type> {
contents: Type;
}
let box:Box<string>;
上面示例中,使用泛型接口时,需要给出类型参数的值(本例是string
)。
下面是另一个例子。
interface Comparator<T> {
compareTo(value:T): number;
}
class Rectangle implements Comparator<Rectangle> {
compareTo(value:Rectangle): number {
// ...
}
}
上面示例中,先定义了一个泛型接口,然后将这个接口用于一个类。
泛型接口还有第二种写法。
interface Fn {
<Type>(arg:Type): Type;
}
function id<Type>(arg:Type): Type {
return arg;
}
let myId:Fn = id;
上面示例中,Fn
的类型参数Type
的具体类型,需要函数id
在使用时提供。所以,最后一行的赋值语句不需要给出Type
的具体类型。
此外,第二种写法还有一个差异之处。那就是它的类型参数定义在某个方法之中,其他属性和方法不能使用该类型参数。前面的第一种写法,类型参数定义在整个接口,接口内部的所有属性和方法都可以使用该类型参数。
类的泛型写法
泛型类的类型参数写在类名后面。
class Pair<K, V> {
key: K;
value: V;
}
下面是继承泛型类的例子。
class A<T> {
value: T;
}
class B extends A<any> {
}
上面示例中,类A
有一个类型参数T
,使用时必须给出T
的类型,所以类B
继承时要写成A<any>
。
泛型也可以用在类表达式。
const Container = class<T> {
constructor(private readonly data:T) {}
};
const a = new Container<boolean>(true);
const b = new Container<number>(0);
上面示例中,新建实例时,需要同时给出类型参数T
和类参数data
的值。
下面是另一个例子。
class C<NumType> {
value!: NumType;
add!: (x: NumType, y: NumType) => NumType;
}
let foo = new C<number>();
foo.value = 0;
foo.add = function (x, y) {
return x + y;
};
上面示例中,先新建类C
的实例foo
,然后再定义实例的value
属性和add()
方法。类的定义中,属性和方法后面的感叹号是非空断言,告诉 TypeScript 它们都是非空的,后面会赋值。
JavaScript 的类本质上是一个构造函数,因此也可以把泛型类写成构造函数。
type MyClass<T> = new (...args: any[]) => T;
// 或者
interface MyClass<T> {
new(...args: any[]): T;
}
// 用法实例
function createInstance<T>(
AnyClass: MyClass<T>,
...args: any[]
):T {
return new AnyClass(...args);
}
上面示例中,函数createInstance()
的第一个参数AnyClass
是构造函数(也可以是一个类),它的类型是MyClass<T>
,这里的T
是createInstance()
的类型参数,在该函数调用时再指定具体类型。
注意,泛型类描述的是类的实例,不包括静态属性和静态方法,因为这两者定义在类的本身。因此,它们不能引用类型参数。
class C<T> {
static data: T; // 报错
constructor(public value:T) {}
}
上面示例中,静态属性data
引用了类型参数T
,这是不可以的,因为类型参数只能用于实例属性和实例方法,所以报错了。
类型别名的泛型写法
type 命令定义的类型别名,也可以使用泛型。
type Nullable<T> = T | undefined | null;
上面示例中,Nullable<T>
是一个泛型,只要传入一个类型,就可以得到这个类型与undefined
和null
的一个联合类型。
下面是另一个例子。
type Container<T> = { value: T };
const a: Container<number> = { value: 0 };
const b: Container<string> = { value: 'b' };
下面是定义树形结构的例子。
type Tree<T> = {
value: T;
left: Tree<T> | null;
right: Tree<T> | null;
};
上面示例中,类型别名Tree
内部递归引用了Tree
自身。
类型参数的默认值
类型参数可以设置默认值。使用时,如果没有给出类型参数的值,就会使用默认值。
function getFirst<T = string>(
arr:T[]
):T {
return arr[0];
}
上面示例中,T = string
表示类型参数的默认值是string
。调用getFirst()
时,如果不给出T
的值,TypeScript 就认为T
等于string
。
但是,因为 TypeScript 会从实际参数推断出T
的值,从而覆盖掉默认值,所以下面的代码不会报错。
getFirst([1, 2, 3]) // 正确
上面示例中,实际参数是[1, 2, 3]
,TypeScript 推断 T 等于number
,从而覆盖掉默认值string
。
类型参数的默认值,往往用在类中。
class Generic<T = string> {
list:T[] = []
add(t:T) {
this.list.push(t)
}
}
上面示例中,类Generic
有一个类型参数T
,默认值为string
。这意味着,属性list
默认是一个字符串数组,方法add()
的默认参数是一个字符串。
const g = new Generic();
g.add(4) // 报错
g.add('hello') // 正确
上面示例中,新建Generic
的实例g
时,没有给出类型参数T
的值,所以T
就等于string
。因此,向add()
方法传入一个数值会报错,传入字符串就不会。
const g = new Generic<number>();
g.add(4) // 正确
g.add('hello') // 报错
上面示例中,新建实例g
时,给出了类型参数T
的值是number
,因此add()
方法传入数值不会报错,传入字符串会报错。
一旦类型参数有默认值,就表示它是可选参数。如果有多个类型参数,可选参数必须在必选参数之后。
<T = boolean, U> // 错误
<T, U = boolean> // 正确
上面示例中,依次有两个类型参数T
和U
。如果T
是可选参数,U
不是,就会报错。
数组的泛型表示
《数组》一章提到过,数组类型有一种表示方法是Array<T>
。这就是泛型的写法,Array
是 TypeScript 原生的一个类型接口,T
是它的类型参数。声明数组时,需要提供T
的值。
let arr:Array<number> = [1, 2, 3];
上面的示例中,Array<number>
就是一个泛型,类型参数的值是number
,表示该数组的全部成员都是数值。
同样的,如果数组成员都是字符串,那么类型就写成Array<string>
。事实上,在 TypeScript 内部,数组类型的另一种写法number[]
、string[]
,只是Array<number>
、Array<string>
的简写形式。
在 TypeScript 内部,Array
是一个泛型接口,类型定义基本是下面的样子。
interface Array<Type> {
length: number;
pop(): Type|undefined;
push(...items:Type[]): number;
// ...
}
上面代码中,push()
方法的参数item
的类型是Type[]
,跟Array()
的参数类型Type
保持一致,表示只能添加同类型的成员。调用push()
的时候,TypeScript 就会检查两者是否一致。
其他的 TypeScript 内部数据结构,比如Map
、Set
和Promise
,其实也是泛型接口,完整的写法是Map<K, V>
、Set<T>
和Promise<T>
。
TypeScript 默认还提供一个ReadonlyArray<T>
接口,表示只读数组。
function doStuff(
values:ReadonlyArray<string>
) {
values.push('hello!'); // 报错
}
上面示例中,参数values
的类型是ReadonlyArray<string>
,表示不能修改这个数组,所以函数体内部新增数组成员就会报错。因此,如果不希望函数内部改动参数数组,就可以将该参数数组声明为ReadonlyArray<T>
类型。