# 泛型
泛型(Generics)是指在定义函数、接口或类的时候,不预先指定具体的类型,而在使用的时候再指定类型的一种特性。
反映出参数与返回值之间的类型关系。泛型的特点就是带有类型参数。参数要放在一对尖括号<>里面,可以将其理解为类型声明需要的变量,需要在调用时传入具体的参数类型。类型参数的名字,可以随便取,但是必须为合法的标识符。
泛型本质上是一个类型函数,通过输入参数,获得结果,两者是一一对应关系。
function getFirst<T>(arr:T[]):T {
return arr[0];
} // 函数getFirst()的参数类型是T[],返回值类型是T,就清楚地表示了两者之间的关系
getFirst<number>([1, 2, 3]) //函数调用时,需要提供类型参数。
getFirst([1, 2, 3]) //为了方便,函数调用时,往往省略不写类型参数的值,让TS自己推断
//有些复杂的使用场景,TypeScript 可能推断不出类型参数的值,这时就必须显式给出了。
comb<number|string>([1, 2], ['a', 'b']) // 正确
2
3
4
5
6
7
泛型可以理解成一段类型逻辑,需要类型参数来表达。有了类型参数以后,可以在输入类型与输出类型之间,建立一一对应关系。
# 泛型的写法
泛型主要用在四个场合:函数、接口、类和别名。
# 函数的泛型写法
function关键字定义的泛型函数,类型参数放在尖括号中,写在函数名后面。
function id<T>(arg:T):T {
return arg;
}
2
3
那么对于变量形式定义的函数,泛型有下面两种写法。
// 写法一
let myId:<T>(arg:T) => T = id;
// 写法二
let myId:{ <T>(arg:T): T } = id;
2
3
4
# 接口的泛型写法
使用接口的方式来定义一个函数需要符合的形状,也可以使用含有泛型的接口来定义函数的形状,可以把泛型参数提前到接口名上,此时在使用泛型接口的时候,需要定义泛型的类型。
// 第一种写法,类型参数定义在整个接口,接口内部的所有属性和方法都可以使用该类型参数--------
interface Box<Type> {
contents: Type;
}
let box:Box<string>; // 使用泛型接口时,需要给出类型参数的值(本例是string)
// 第二种写法,类型参数定义在某个方法之中,其他属性和方法不能使用该类型参数---------------
interface Fn {
<Type>(arg:Type): Type; // Fn的类型参数Type的具体类型,需要函数id在使用时提供
}
function id<Type>(arg:Type): Type {
return arg;
}
let myId:Fn = id; // 所以,最后一行的赋值语句不需要给出Type的具体类型。
2
3
4
5
6
7
8
9
10
11
12
13
# 类的泛型写法
与泛型接口类似,泛型也可以用于类的类型定义中。泛型类的类型参数写在类名后面。
class A<T> { // 类A有一个类型参数T
value: T;
}
class B extends A<any> { // 使用时必须给出T的类型
}
// 泛型也可以用在类表达式
const Container = class<T> {
constructor(private readonly data:T) {}
}; // 新建实例时,需要同时给出类型参数T和类参数data的值
const a = new Container<boolean>(true);
const b = new Container<number>(0);
2
3
4
5
6
7
8
9
10
11
JavaScript 的类本质上是一个构造函数,因此也可以把泛型类写成构造函数。
type MyClass<T> = new (...args: any[]) => T;
// 或者
interface MyClass<T> {
new(...args: any[]): T;
}
// 用法实例
function createInstance<T>( // T是createInstance()的类型参数,调用时再指定具体类型
AnyClass: MyClass<T>, // AnyClass是构造函数(也可以是一个类),它的类型是MyClass<T>
...args: any[]
):T {
return new AnyClass(...args);
}
2
3
4
5
6
7
8
9
10
11
12
注意,泛型类描述的是类的实例,不包括静态属性和静态方法,因为这两者定义在类的本身。因此,它们不能引用类型参数。类型参数只能用于实例属性和实例方法。
# 类型别名的泛型写法
type 命令定义的类型别名,也可以使用泛型。
type Nullable<T> = T | undefined | null; //传入一个类型,得到它与undefined和null的联合类型
# 类型参数的默认值
可以为泛型中的类型参数指定默认类型。当使用泛型时没有在代码中直接指定类型参数,从实际值参数中也无法推测出时,这个默认类型就会起作用。
类型参数可以设置默认值。使用时,如果没有给出类型参数的值,就会使用默认值。用在函数中时,TypeScript会从实际参数推断出T的值,从而覆盖掉默认值。类型参数的默认值,往往用在类中。
类型参数有默认值,就表示它是可选参数。如果有多个类型参数,可选参数必须在必选参数之后。
# 数组的泛型表示
Array<any>
允许数组的每一项都为任意类型。但是我们预期的是,数组中每一项都应该是输入的 value 的类型。这时候,泛型就派上用场了,我们在函数名后添加了 <T>
,其中 T 用来指代任意输入的类型,在后面的输入 value: T 和输出 Array<T>
中即可使用了。接着在调用的时候,可以指定它具体的类型为 string。当然,也可以不手动指定,而让类型推论自动推算出来。
function createArray<T>(length: number, value: T): Array<T> {
let result: T[] = [];
for (let i = 0; i < length; i++) {
result[i] = value;
}
return result;
}
createArray<string>(3, 'x'); // ['x', 'x', 'x']
2
3
4
5
6
7
8
事实上,在TS内部,数组类型的另一种写法number[]、string[],只是Array<number>
、Array<string>
的简写形式。
在TypeScript内部,Array是一个泛型接口,还提供一个ReadonlyArray<T>
接口,表示只读数组。
let arr:Array<number> = [1, 2, 3]; // 表示该数组的全部成员都是数值
function doStuff(
values:ReadonlyArray<string> // 表示不能修改这个数组
) {
values.push('hello!'); // 报错
}
2
3
4
5
6
# 类型参数的约束条件
TypeScript提供了一种语法,允许在类型参数上面写明约束条件,如果不满足条件,编译时就会报错。这样也可以有良好的语义,对类型参数进行说明。
/* TypeParameter表示类型参数,
extends是关键字,这是必须的,
ConstraintType表示类型参数要满足的条件 */
<TypeParameter extends ConstraintType> // 即类型参数应该是ConstraintType的子类型
2
3
4
类型参数可以同时设置约束条件和默认值,前提是默认值必须满足约束条件。
如果有多个类型参数,一个类型参数的约束条件可以引用其他参数。但是,约束条件不能引用类型参数自身。同理,多个类型参数也不能互相约束。
# 使用注意点
- 尽量少用泛型。泛型虽然灵活,但是会加大代码的复杂性,使其变得难读难写。
- 类型参数越少越好。多一个类型参数,多一道替换步骤,加大复杂性。
- 类型参数需要出现两次。只有当类型参数用到两次或两次以上,才是泛型的适用场合。
- 泛型可以嵌套。类型参数可以是另一个泛型。
# 类型断言
类型断言可以用来手动指定一个值的类型,允许开发者在代码中断言某个值的类型,告诉编译器此处的值是什么类型。TypeScript 一旦发现存在类型断言,就不再对该值进行类型推断,而是直接采用断言给出的类型。类型断言并不是真的改变一个值的类型,而是提示编译器,应该如何处理这个值。
这种做法的实质是,允许开发者在某个位置绕过编译器的类型推断,让本来通不过类型检查的代码能够通过,避免编译器报错。使用类型断言时一定要格外小心,减少不必要的运行时错误。
// 语法一:<类型>值
<Type>value
// 语法二:值 as 类型 推荐使用语法二
value as Type
2
3
4
类型断言的一大用处是,指定 unknown 类型的变量的具体类型。unknown 类型的变量value不能直接赋值给其他类型的变量,但是可以将它断言为其他类型,这样就可以赋值给别的变量了。
类型断言的常见用途有以下几种: ① 将一个联合类型断言为其中一个类型(联合类型可以被断言为其中一个类型) ② 将一个父类断言为更加具体的子类(父类可以被断言为子类) ③ 将任何一个类型断言为 any(任何类型都可以被断言为 any) ④ 将 any 断言为一个具体的类型(any 可以被断言为任何类型) ⑤ 要使得 A 能够被断言为 B,只需要 A 兼容 B 或 B 兼容 A 即可 其实前四种情况都是最后一个的特例。
- 类型断言 vs 类型转换:类型断言只会影响TS编译时的类型,语句在编译结果中会被删除。类型断言不是类型转换,它不会真的影响到变量的类型。若要进行类型转换,需要直接调用类型转换的方法。
- 类型断言 vs 类型声明:类型声明比类型断言更加严格,最好优先使用类型声明。核心区别如下:
interface Animal {
name: string;
}
interface Cat {
name: string;
run(): void;
}
const animal: Animal = {
name: 'tom'
};
let tom = animal as Cat; // 正常。Animal兼容Cat,故可以将animal断言为Cat赋值给tom
let tom: Cat = animal; // 报错。不允许将animal赋值为Cat类型的tom
2
3
4
5
6
7
8
9
10
11
12
animal 断言为 Cat,只需要满足 Animal 兼容 Cat 或 Cat 兼容 Animal 即可。
animal 赋值给 tom,需要满足 Cat 兼容 Animal 才行,但是 Cat 并不兼容 Animal。
类型断言 vs 泛型:类型断言主要用于处理编译器无法推断或确定类型的情况,而泛型则用于创建灵活且可重用的代码结构。
# 类型断言的条件
类型断言并不意味着,可以把某个值断言为任意类型。类型断言要求实际的类型与断言的类型兼容,实际类型可以断言为一个更加宽泛的类型(父类型),也可以断言为一个更加精确的类型(子类型),但不能断言为一个完全无关的类型。
但是,如果真的要断言成一个完全无关的类型,也是可以做到的。那就是连续进行两次类型断言,先断言成 unknown 类型或 any 类型,然后再断言为目标类型。因为any类型和unknown类型是所有其他类型的父类型,所以可以作为两种完全无关的类型的中介。
// 或者写成 <T><unknown>expr
expr as unknown as T // 第一次断言为unknown类型,第二次断言为T类型。这样expr就可以断言成任意类型T而不报错
2
# as const 断言
as const会将字面量的类型断言为不可变类型,缩小成 TypeScript 允许的最小类型。
如果没有声明变量类型,let命令声明的变量,会被类型推断为TypeScript内置的基本类型之一;const命令声明的变量,则被推断为值类型常量。有些时候,let变量会出现一些意想不到的报错,变更成const变量就能消除报错。
let s1 = 'JavaScript'; // 类型推断为基本类型 string
const s2 = 'JavaScript'; // 类型推断为字符串 “JavaScript”
// 后者是前者的子类型,相当于 const 命令有更强的限定作用,可以缩小变量的类型范围
2
3
TypeScript提供了一种特殊的类型断言as const,用于告诉编译器,推断类型时,可以将这个值推断为常量,即把 let 变量断言为 const 变量,从而把内置的基本类型变更为值类型。使用了as const断言以后,let 变量就不能再改变值了。
as const断言只能用于字面量,不能用于变量。as const也不能用于表达式。可写成前置形式。
// 后置形式
expr as const
// 前置形式
<const>expr
2
3
4
as const断言可用于整个对象,也可用于对象的单个属性,这时它的类型缩小效果是不一样的。
const v1 = {
x: 1,
y: 2,
}; // 类型是 { x: number; y: number; }
const v2 = {
x: 1 as const,
y: 2,
}; // 类型是 { x: 1; y: number; }
const v3 = {
x: 1,
y: 2,
} as const; // 类型是 { readonly x: 1; readonly y: 2; }
2
3
4
5
6
7
8
9
10
11
12
由于as const会将数组变成只读元组,所以很适合用于函数的rest参数。事实上,对于固定参数个数的函数,如果传入的参数包含扩展运算符,那么扩展运算符只能用于元组。只有当函数定义使用了 rest 参数,扩展运算符才能用于数组。
Enum 成员也可以使用as const断言。
# 非空断言
对于那些可能为空的变量(即可能等于undefined或null),TypeScript 提供了非空断言,保证这些变量不会为空,写法是在变量名后面加上感叹号!。
非空断言在实际编程中很有用,有时可以省去一些额外的判断。不过,非空断言会造成安全隐患,只有在确定一个表达式的值不为空时才能使用。比较保险的做法还是手动检查一下是否为空。
非空断言还可以用于赋值断言。TypeScript 有一个编译设置,要求类的属性必须初始化(即有初始值),如果不对属性赋值就会报错,可以使用非空断言表示这两个属性肯定会有值,这样就不会报错了。
非空断言只有在打开编译选项strictNullChecks时才有意义。如果不打开这个选项,编译器就不会检查某个变量是否可能为undefined或null。
function f(x?:number|null) { // 参数x的类型是number|null,即可能为空
validateNumber(x); // 自定义函数,确保 x 是数值
console.log(x!.toFixed()); // 前置检验,变量x肯定不会为空
}
2
3
4
# 断言函数
断言函数是一种特殊函数,用于保证函数参数符合某种类型。如果函数参数达不到要求,就会抛出错误,中断程序执行;如果达到要求,就不进行任何操作,让代码按照正常流程运行。
/* asserts和is都是关键词,value是函数的参数名,string是函数参数的预期类型
意思是,该函数用来断言参数value的类型是string,如果达不到要求,程序就会在这里中断*/
function isString(value:unknown):asserts value is string {
if (typeof value !== 'string')
throw new Error('Not a string');
} // 只要执行了该函数,对应的变量都为断言的类型
2
3
4
5
6
注意,函数返回值的断言写法,只是用来更清晰地表达函数意图,真正的检查是需要开发者自己部署的。而且,如果内部的检查与断言不一致,TypeScript 也不会报错。 断言函数的asserts语句等同于void类型,所以如果返回除了undefined和null以外的值,会报错。
如果要断言参数非空,可以使用工具类型NonNullable<T>
。
断言函数与类型保护函数是两种不同的函数。它们的区别是,断言函数不返回值,而类型保护函数总是返回一个布尔值。
如果要断言某个参数保证为真(即不等于false、undefined和null),TypeScript 提供了断言函数的一种简写形式。同样的,参数为真的实际检查需要开发者自己实现。
function assert(x:unknown):asserts x { // asserts x省略了谓语和宾语,表示参数x保证为真true
// ...
}
2
3