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