外观
03.函数与泛型
11059字约37分钟
2024-05-31
函数声明与调用
函数我们之前一直在使用了,基本上和 Javascript 声明函数是一样的,只是在 Typescript 中我们需要现实的注解函数的参数
function add(a: number, b: number): number {
return a + b;
}
函数的返回类型 Typescript 可以自己推导出来,不过也可以显示的注解
当然对于函数的声明也和 Javascript 一样,无论是具名函数,还是函数表达式,还是箭头函数,都没有任何问题
function sayHello1(name: string) {
return 'hello ' + name;
}
const sayHello2 = function (name: string) {
return 'hello ' + name;
};
const sayHello3 = (name: string): string => {
return 'hello ' + name;
};
const sayHello4 = (name: string) => 'hello ' + name;
const sayHello5 = (name: string) => {
if (name === 'admin') {
return 'hello admin';
}
return;
};
在函数调用的时候,我们就无需提供任何额外的类型信息了,直接传入实参即可,Typescript 将检查实参是否与函数的形参类型兼容
const sum = add(1, 2);
返回的结果也不需要你纠结,类型是固定的。
如果忘记传入某个参数,或者传入的参数类型有误,Typescript 将指出问题
const sum = add(1); //error 应有2个参数,但获得1个
const str = sayHello1(123); // error 类型“123”的参数不能赋给类型“string”的参数
可选参数与默认参数
同样可以使用可选符号?
把参数标记为可选。
声明函数的参数时,必要的参数放在前面,然后才是可选参数
function sendMessage(userId: number, message?: string) {
console.log(userId, message || '');
}
sendMessage(1);
与在 Javascript 中一样,可以为可选参数提供默认值,这样做在语义上与把参数标记为可选是一样的。
带默认值的参数调用时可以不用传入参数的值,并且带默认值的参数不要求一定要放在参数列表的末尾,可选参数是必须放在末尾。不过默认参数放在前面也没有任何意义,约定俗成我们都还是放在末尾。
function sendMessage(userId: number, message = 'hello') {
console.log(userId, message || '');
}
剩余参数
函数有时候接受参数的数量是不定的,如果不想让参数固定,在以前,我们可以使用 arguments 这个隐藏参数搞定
function sum() {
return Array.from(arguments).reduce((result, item) => result + item, 0);
}
sum(1, 2, 3, 4, 5); //error
使用 arguments 的坏处显而易见,就算我们之前写 Javascript,你也不会使用 arguments 去处理这些问题。在 Typescript 更加如此。
- arguments 是一个伪数组,使用的时候还需要转换才能使用数组相关方法
- 在调用的时候,会明确的提醒你,这个函数不需要参数,但是你却给了参数
- 还有一个隐藏的点是,reduce 函数调用的回调函数中,所有的参数全是 any
要确保安全可靠,我们当然应该使用剩余参数(rest parameter)
function sum(...numbers: number[]) {
return numbers.reduce((result, item) => result + item, 0);
}
sum(1, 2, 3, 4, 5);
使用剩余参数,上面所有的问题都解决了:
- 剩余参数是一个数组,并且我们可以使用 Typescript 定义具体的类型
- 调用函数的时候,也会有明确的提示
- reduce 回调函数中的参数全部都有具体的类型
当然,唯一需要注意一点是:
一个函数最多只能有一个剩余参数,而且必须位于参数列表的最后
this 的类型注解
在 Javascript 中,我们可能会写出下面的代码(可以在浏览器上测试)
function showDate() {
return `${this.getFullYear()}-${this.getMonth() + 1}-${this.getDate()}`;
}
这个函数里面的this
,其实很明显是为了获取Date
对象,所以,我们调用的时候,会使用call
方法绑定具体的 Date 对象
function showDate() {
return `${this.getFullYear()}-${this.getMonth() + 1}-${this.getDate()}`;
}
showDate.call(new Date());
如果打开了
"strict":true
,默认也会开启"noImplicitThis": true
,没有显示的指定 this 的类型也会提示错误,在一般的函数中,如果要使用this
,就必须显示的标注this
的类型注意:
noImplicitThis
不强制要求类和对象的函数必须要注解this
当然,这种写法本身就是很危险的,谁也不知道这个showDate
方法应该如何去调用,不过,在Typescript
中如果出现了这种写法,我们可以使用this
的类型注解,确保需要传入对应的 this 对象
function showDate(this: Date) {
return `${this.getFullYear()}-${this.getMonth() + 1}-${this.getDate()}`;
}
// showDate(); //error
showDate.call(new Date());
// showDate.call(null); // 如果"strictBindCallApply":false不会报错
如果函数使用
this
,可以在函数的第一个参数中声明this
类型(放在其他参数之前),这样在调用函数的时候,Typescript 将确保this
是你预期的类型
this
不是常规的参数,而是保留字,是函数签名的一部分
"strictBindCallApply": true
开启这个配置选项能比较安全的调用.call
、.apply
和.bind
,会检查传入的参数是否和this
匹配,当然strictBindCallApply
配置也是属于strict
家族的一员
调用签名
调用签名
我们现在在讲函数,那么我们声明的函数到底是什么类型呢?就是一个Function
类型吗?
其实,和我们之前讲的对象类型一样,object 能描述所有对象,function 也可以描述所有函数,但是并不能体现函数的具体类型
function add(a: number, b: number) {
return a + b;
}
像上面这样的函数,我们可以用 Typescript 进行描述
(a: number, b: number) => number;
这是 Typescript 表示函数类型的句法,也称为调用签名。
函数的调用签名只包含类型层面的代码,即只有类型,没有值。因此,函数的调用签名可以表示参数的类型、this 的类型、返回值的类型。剩余参数的类型和可选参数的类型,但是无法表示默认值(因为默认值是值,不是类型)。调用签名没有函数体,无法推导出返回类型,所以必须显式的注解
函数签名其实对我们写函数也有指导意义。
type Greet = (name: string) => string;
function greet(name: string) {
return name;
}
type Log = (userId: number, message?: string) => void;
function log(userId: number, message = 'hello') {}
type SumFn = (...numbers: number[]) => number;
function sumFn(...numbers: number[]): number {
return numbers.reduce((total, n) => total + n, 0);
}
我们可以把调用签名和函数表达式结合起来
type Log = (userId: number, message?: string) => void;
const log: Log = (userId, message) => {
console.log(userId, message);
};
将函数表达式注解为 Log 类型后,你会发现,不必再此注解参数的类型,因为在定义 Log 类型的时候已经注解了 message 和 userId 的类型。Typescript 能从 Log 中推导出来,同理,返回值类型其实也是一样(当然返回值类型本身就能帮我们进行推导)
上面使用的都是类型别名,当然也能使用interface
interface Log {
(userId: number, message?: string): void;
}
// type Log = (userId: number, message?: string) => void;
const log: Log = (userId, message) => {
console.log(userId, message);
};
其实,如果我们已经有具体的函数了,完全可以通过typeof
获取函数的类型声明
function greet(name: string) {
return name;
}
function log(message: string, userId = 'xxx1') {}
function sumFn(...numbers: number[]): number {
return numbers.reduce((total, n) => total + n, 0);
}
type Greet = typeof greet;
type Log = typeof log;
type SumFn = typeof sumFn;
如果是有回调函数,一样添加回调函数相关声明就行了
function handleData(
data: string,
callback: (err: Error | null, result: string) => void
): void {
// 模拟一些操作
if (data === 'error') {
callback(new Error('An error occurred'), '');
} else {
callback(null, `Processed data: ${data}`);
}
}
当然可以把callback
的声明再提取成一个类型别名
// 注意:没有用模块化,类型别名的命名容易和全局变量冲突, 比如ErrorCallback
type ErrorCB = (err: Error | null, result: string) => void;
function handleData(data: string, callback:ErrorCB):void {
......
}
当然也能够整个函数提取为类型别名或者接口,交给函数表达式处理:
type ErrorCB = (err: Error | null, result: string) => void;
type HandleData = (data: string, callback: ErrorCB) => void;
const handleData: HandleData = (data, callback) => {
// 模拟一些操作
if (data === 'error') {
callback(new Error('An error occurred'), '');
} else {
callback(null, `Processed data: ${data}`);
}
};
当然了,也能使用 typeof 直接获取已有函数的类型声明:
type HandleData = typeof handleData;
在对象字面量类型中声明函数类型,和普通的函数类型声明没有什么差别:
type User = {
name: string;
id: number;
show: (name: string) => void;
info(id: number): string;
};
上下文类型推导
直接把函数类型进行声明,Typescript 能从上下文中推导出参数的类型。这是 Typescript 类型推导的一个强大特性,我们一般称为上下文类型推导。
上下文类型推导,在某些时候非常的有用,比如回调函数中
function times(fn: (index: number) => void, n: number) {
for (let i = 0; i < n; i++) {
fn(i);
}
}
times((n: number) => console.log(n), 4);
上面的函数 times 的回调函数 fn,强调需要一个 number 类型的参数,并且没有返回类型。
当我在调用 times 函数的时候,当然需要传入这个一个回调函数(n:number) => console.log(n)
按照正常情况,既然是一个函数,那么函数中传递的参数,就应该声明类型。
但是,这里其实我们可以省略,直接简写为:
times((n) => console.log(n), 4);
因为 Typescript 能从上下文推导出 n 是一个数字,因为在 times 的签名中,我们声明回调函数 f 的参数 index 是一个数字。那么 Typescript 就能推导出,下面传入的回调函数中的参数 n,就是那个参数,该参数的类型必然应该是 number 类型
但是,也有需要注意的地方:
如果回调函数的声明不是在行内直接声明,那么 Typescript 无法推导出它的类型
const fn = (n) => console.log(n); // error 参数n隐式具有“any”类型
times(fn, 4);
这个错误当然也很好理解,外部直接声明的 fn 相当于是一个全新的函数了,在声明的时候和 times 函数是没有任何关联的,当然不可能进行上下文类型推导
重载
重载
在某些逻辑较复杂的情况下,函数可能有多组入参类型和返回值类型.
比如有这样简单的需求:函数有两个参数,要求两个参数如果都是 number 类型,那么就做乘法操作,返回计算结果,如果两个参数都是字符串,就做字符串拼接,并返回字符串拼接结果。其他情况直接抛出异常:参数类型必须相同
首先,很多初学者的直观想法是,我直接声明两个不同的函数不就完了。
首先这种做法不是我们要讲的这个概念。另外,其实我们做的事情是一样的,比如
console.log()
函数,我们可以传递 number,string,boolean 甚至对象,都能实现打印,但是使用用到的都是一个console.log()
函数,如果不同的参数,对应不同的函数名,那这样对于使用者来说,是极大的心智负担。
根据这样的做法,我们很容易写出下面的代码:
function combine(a: number | string, b: number | string) {
if (typeof a === 'number' && typeof b === 'number') {
return a * b;
} else if (typeof a === 'string' && typeof b === 'string') {
return a + b;
}
throw new Error('must be of the same type');
}
const result = combine(2, 3);
这个代码,咋看没有任何问题,但实际上隐含了很多问题在里面。
第一个,这样的代码实际并没有起到类型约束的效果,我们要类型系统,目的就是要在编译期间就帮我们提示错误,避免运行时错误,然后再回来调试。而现在这个代码的问题:
- 参数可以输
number
也可以输入string
,并没有在编译时就给我提示不能输入不同类型的参数 - 返回值类型并不固定,两个参数是
number
,那么返回的类型,就应该一定是number
,但是现在返回的是string | number
第二个,是类型编程语言的常识问题:在很多静态语言中,一旦指定了特定的参数和返回类型,就只能使用相应的参数调用函数,而且返回值的类型始终如一。而我们已经习惯了 Javascript 的写法,了解了一点点 Typescript 语法,就觉得上面应该是没问题的啊,有类型的限定,有函数的自动推导。
其实,在很多静态语言中,上面的写法根本不成立,要么参数指定是数值类型,要么参数固定是字符串类型,也没有所谓的推导,函数返回值类型也必须指定,比如下面的伪代码:
function combine(a: number, b: number): number {
return a * b;
}
function combine(a: string, b: string): string {
return a + b;
}
声明函数的时候就固定好,这样省去了判断的麻烦。
所以,简单来说,其实 Typescript 对比其他静态编程语言,还是具有一定的动态性,函数的输出类型取决于输入类型的推导。你可以把这个理解为 Typescript 是更先进的类型系统...也可以理解为是为了兼容 Javascript 的动态性不得已而为之。
基于这个问题,我们可以使用**函数重载签名(Overload Signature)**来解决这个问题
function combine(a: number, b: number): number;
function combine(a: string, b: string): string;
function combine(a: number | string, b: number | string) {
if (typeof a === 'number' && typeof b === 'number') {
return a * b;
} else if (typeof a === 'string' && typeof b === 'string') {
return a + b;
}
throw new Error('must be of the same type');
}
const result = combine(2, 3);
这里我们的三个 function combine
其实具有不同的意义:
function combine(a: number, b: number): number;
,重载签名一,传入 a 和 b 的值为 number 时,函数返回值类型为 number 。function combine(a: string, b: string): string;
,重载签名二,传入 a 和 b 的值为 string 时,函数返回值类型为 string 。function combine(a: number | string, b: number | string)
,函数的实现签名,会包含重载签名的所有可能情况
注意:重载签名和实现签名必须放在一起,中间不能插入其他的内容
继续看下面的例子,根据函数传递的参数,如果传入string
类型,就转换为 10 进制的number
类型,如果传入的是number
类型或者其他类型,就调用toString()
转换为string
类型
function changeType(x: string | number): number | string {
return typeof x === 'string' ? parseInt(x, 10) : x.toString();
}
changeType('1');
这样写代码依然和之前的问题一样,不能在编译时提供帮助,因此加上重载签名
function changeType(x: string): number;
function changeType(x: number): string;
function changeType(x: string | number): number | string {
return typeof x === 'string' ? parseInt(x, 10) : x.toString();
}
changeType('2');
不过在声明重载的时候,还是有一些细节需要注意,比如,我们模拟DOM API
中createElement
函数的处理,这个函数大家都用过,参数传递具体的标签名字符串,就帮我们创建对应的 HTML 元素
function createElement(tag: 'a'): HTMLAnchorElement;
function createElement(tag: 'canvas'): HTMLCanvasElement;
function createElement(tag: 'table'): HTMLTableElement;
function createElement(tag: string): HTMLElement {
return document.createElement(tag);
}
const a = createElement('a'); // ok
const b = createElement('b'); // error
由于重载签名只有a | canvas | table
的情况,因此,如果调用函数的时候,传入不是这三个类型的字符串就会报错,其实我们可以再加入一个兜底的重载签名,如果用户传入自定义标签名,或者一些前沿性的标签名,我们直接返回一般性的HTMLElement
function createElement(tag: "a"): HTMLAnchorElement;
function createElement(tag: "canvas"): HTMLCanvasElement;
function createElement(tag: "table"): HTMLTableElement;
+function createElement(tag: string): HTMLElement;
function createElement(tag: string): HTMLElement {
return document.createElement(tag);
}
const a = createElement("a");
需要注意的是:拥有多个重载声明的函数在被调用时,是按照重载的声明顺序往下查找的,简单来说,特殊的子类型,比如类型字面量等我们放在上面,兜底的类型,我们应该放在最后,如果你讲兜底的类型放在的最上面,无论如果,函数签名找到的都是第一个
实际上,
TypeScript
中的重载是伪重载,它只有一个具体实现,其重载体现在方法调用的签名上而非具体实现上。而在如Java
等语言中,重载体现在多个名称一致但入参不同的函数实现上,这才是更广义上的函数重载。
泛型
理解泛型
前面讲解了这么多,突然有一天有一个很简单的需求摆在你的面前,让你用 Typescript 封装一个函数,传入任意类型的参数,传入什么类型就返回什么类型......
对于 Javascript 来说,这有什么难度?甚至用箭头函数写更加简单
function identity(value) {
return value;
}
const identity = (value) => value;
但是对于 Typescript 来说,就有问题了,参数要有约束,返回的类型也应该和参数是同一个类型,那这该怎么办?
难道传入number
?传入string
?
function identity(value: number) {
return value;
}
但是这样不是限定死了,就只能是参数限定的类型吗?
既然要任何类型,首先你会想到用any
function identity(value: any) {
return value;
}
const s = identity('hello');
但是用了any
和直接写 Javascript 有什么区别呢?这就丧失了类型检查的效果。就算现在不报错了,最后得到的变量其实也不知道到底是什么类型,比如上面代码中的变量,并没有调用字符串的相关方法的提示。这就完全丧失了使用 Typescript 的意义。
当然这个时候,我们就会想到前面刚刚讲的函数重载
function identity(value: number): number;
function identity(value: string): string;
function identity(value: boolean): boolean;
function identity(value: number | string | boolean): number | string | boolean {
return value;
}
const s = identity('aaa');
这么写貌似没问题了,但是仅仅就只有 3 个基本类型啊,如果还有不同类型的数组呢?不同类型的数组都还好说,无非就继续往上加类型而已
function identity(value: number): number;
function identity(value: string): string;
function identity(value: boolean): boolean;
function identity(value: number[]): number[];
function identity(value: string[]): string[];
function identity(
value: number | string | boolean | number[] | string[]
): number | string | boolean | number[] | string[] {
return value;
}
const s1 = identity([1, 2, 3, 4]);
const s2 = identity(['a', 'b', 'c', 'd']);
但是如果还有对象类型呢?
function identity(value: number):number;
function identity(value: string):string;
function identity(value: boolean): boolean;
function identity(value: number[]): number[];
function identity(value: string[]): string[];
+function identity(value: object): object;
+function identity(value: number | string | boolean | number[] | string[] | object): number | string | boolean | number[] | string[] | object{
+ return value;
+}
const s1 = identity([1,2,3,4]);
const s2 = identity(["a","b","c","d"]);
+const s3 = identity({ id: 1, name: "aaa" });
+console.log(s3.name) // error 类型“object”上不存在属性“name”
对象类型 object 会报错,为什么?这在我们之前就解释过,object 仅仅表示对象而已,并不知道对象里面具体有什么,那我们就只能使用对象字面量...那这就完全没戏了。谁知道对象字面量里面有多少的内容呢?
那么这里好像就没有更好的办法可以解决了?
如果能利用 Typescript 的自动推导的功能,我不确定当前用什么类型,当用到什么类型的参数的时候,根据我传给你的类型进行推导,只要在我代码用到了推导的类型,那么就都是这个类型,这就是我们要讲的泛型
function identity<T>(value: T): T {
return value;
}
在函数名后,使用尖括号<>
来声明泛型T
,表示传递的泛型的类型,你可以把他先理解为一种占位符号。后面凡是出现一样的这个符号T
,那就表示是一样的类型。
本质上T
其实和我们写的 number,string 等等是一个意思。当我们调用时:
function identity<T>(value: T): T {
return value;
}
type User = {
id: number;
name: string;
};
const s1 = identity<number>(1);
const s2 = identity<string>('a');
const s3 = identity<User>({ id: 1, name: 'aaa' });
console.log(s3.name); // ok
当我们调用的时候,TS 其实可以根据我们传入的参数自动推导泛型的类型,所以,调用的时候,前面的<>
是可以省略的。
const s1 = identity(1);
const s2 = identity('a');
const s3 = identity({ id: 1, name: 'aaa' });
console.log(s3.name); // ok
那为什么是T
?
T
就是一个类型名称,如果愿意,可以使用任意的其他字母名称,例如,A,B,C 等等。按照惯例,经常使用单个大写字母,从
T
开始,依次使用U
,V
,W
等。就算是多个单词
Abb
,Acc
也没有问题,因为泛型字母就表示一个占位符,类型检查器将根据上下文填充具体的类型。不过一般
T
,E
,K
,V
,U
等字母用的比较多而已
再来一个例子:
function getTuple<T>(a: T, b: T) {
return [a, b];
}
const as = getTuple<string>('hello', 'world');
我们之前还写过这样的代码:
function myNumberFilter(
arr: number[],
callback: (item: number, index?: number) => boolean
): number[] {
const result = [];
for (let i = 0; i < arr.length; i++) {
const item = arr[i];
if (callback(item)) {
result.push(item);
}
}
return result;
}
const filterArr1 = myNumberFilter([1, 2, 3, 4, 5], (item) => item % 2 === 0);
console.log(filterArr1);
function filter<T>(
arr: T[],
callback: (item: T, index?: number) => boolean
): T[] {
const result = [];
for (let i = 0; i < arr.length; i++) {
const item = arr[i];
if (callback(item)) {
result.push(item);
}
}
return result;
}
const filterArr2 = filter(['xxx.js', 'aaa.java', 'bbb.md'], (item) =>
item.endsWith('.js')
);
console.log(filterArr2);
编译之后,你会发现myNumberFilter
,filter
这两个函数的内部逻辑是一模一样的。
这其实更进一步验证了我们之前一直讲的,类型系统和逻辑是分离的,我们在编译时把类型修改成什么样,并不会对运行时的逻辑产生任何影响。
但是,泛型让函数的功能,在编译时更具有一般性,比接受具体类型的函数更加强大。
泛型可以理解为一种约束。
当我们把函数的参数注解为fn(n:number)
的时候,参数n
的值就被约束为了number
类型。
同样,泛型T
把T
所在位置的类型约束为T
所绑定的类型
当然,上面的函数是函数声明的写法,函数表达式写法也一样:
const filter = <T>(array: T[], callback: (value: T, index?: number) => boolean): T[] => {
......
}
由于泛型本身十分的方便好用,所以在数组,对象,类型别名,以及后面要讲解的类和接口中都可以使用泛型。
只要有可能就应该使用泛型,这样写出来的代码更具有一般性,可以重复使用,并且简明扼要
类型别名与接口使用泛型
类型别名与接口使用泛型
数组,类型别名,类和接口中都能使用泛型
数组使用泛型:
function unique<T>(array: Array<T>): T[] {
return Array.from(new Set(array));
}
const arr1: number[] = [1, 2, 2, 3, 4, 4];
const arr2: Array<string> = ['a', 'b', 'b', 'c', 'd', 'a'];
const arr3 = unique(arr1);
const arr4 = unique(arr2);
console.log(arr3);
console.log(arr4);
在类型别名中当然也能使用泛型,一般简称为泛型别名。
比如我们在获取后端数据返回内容的时候,一般都是有 code,message 和 data,code 和 message 都好说,但是 data 里面到底有什么是确定不了的,如果我们希望把返回内容封装成一个类型别名,那么就需要使用泛型
type ResultData<T> = {
message: string;
code: number;
data: T;
};
type User = {
id: number;
name: string;
tel: string;
address?: string;
};
type UserData = ResultData<User>;
当然泛型别名之间也能互相调用,函数中也可以调用泛型别名
type MyEvent<T> = {
target: T;
type: string;
};
type TimedEvent<T> = {
event: MyEvent<T>;
from: Date;
to: Date;
};
const myEvent: MyEvent<HTMLButtonElement | null> = {
target: document.querySelector('#btn'),
type: 'click',
};
const timedEvent: TimedEvent<HTMLElement | null> = {
event: {
target: document.querySelector('#div'),
type: 'click',
},
from: new Date(),
to: new Date(),
};
function triggerEvent<T>(event: MyEvent<T>): void {
// ...
}
triggerEvent({
target: document.querySelector('#layer'),
type: 'click',
});
当然,接口中使用泛型和类型别名没啥区别。上面的代码直接将类型别名改为接口就直接使用了。
interface MyEvent<T> {
target: T;
type: string;
}
interface TimedEvent<T> {
event: MyEvent<T>;
from: Date;
to: Date;
}
有时候还可以利用泛型别名给我们写 TS 代码带来一些方便,比如在 Typescript 是strict
声明下,是不能给一个已经固定类型的定义为 null 值的,如果我们平时写 Javascript 的时候习惯了定义某些值的时候给一个 null,那 Typescript 的这种限定就稍稍觉得有那么一点不习惯
type Nullable<T> = T | null | undefined;
const str: Nullable<string> = null;
type User = {
id: number;
name: string;
tel: string;
address?: string;
};
let user: Nullable<User> = null;
user = {
id: 1,
name: 'aaa',
tel: '123456',
};
当然,你也可以在函数的调用签名中使用泛型,其实就是一个特定函数的类型别名嘛,比如之前的 filter 函数,我们可以直接用调用签名进行约束
function filter<T>(
arr: T[],
callback: (item: T, index?: number) => boolean
): T[] {
const result = [];
for (let i = 0; i < arr.length; i++) {
const item = arr[i];
if (callback(item)) {
result.push(item);
}
}
return result;
}
const filterArr2 = filter(['xxx.js', 'aaa.java', 'bbb.md'], (item) =>
item.endsWith('.js')
);
console.log(filterArr2);
上面是之前函数声明的写法
type Filter<T> = (
arr: T[],
callback: (item: T, index?: number) => boolean
) => T[];
现在写成了调用签名,相当于要直接约束某个函数的写法了,所以具体在写函数的时候,就需要传递具体的类型了
type Filter<T> = (
arr: T[],
callback: (item: T, index?: number) => boolean
) => T[];
const myFilter: Filter<number> = (arr, callback) => {
const result = [];
for (let i = 0; i < arr.length; i++) {
const item = arr[i];
if (callback(item)) {
result.push(item);
}
}
return result;
};
不要混淆类型操作
// add
// function add<T>(a: T, b: T) {
// return a + b; // 加号需要有特定类型才能操作
// }
type Add<T> = (a: T, b: T) => T;
const add: Add<string> = (a, b) => a + b;
多泛型
多泛型
考虑一个简单问题:创建一个通用的交换函数,它接受一个包含两个元素的数组,并返回元素交换位置后的数组
function swap<T, U>(pair: [T, U]): [U, T] {
return [pair[1], pair[0]];
}
const result1 = swap([2, 'a']);
const result2 = swap(['hello', { text: 'world' }]);
console.log(result1, result2);
继续考虑这个一个要求,如果我们要封装一个类似于数组的 map 函数,简单来说,给我一个数组,然后根据回调函数,我们能组装成另外一个新的数组,而这个新的数组,可能类型和原来的数组一样,也有可能不一样
// 原生map演示
const arr = [1, 2, 3, 4, 5];
const newArr1 = arr.map((e) => e * 2);
const newArr2 = arr.map((e) => `<div>index${e}</div>`);
console.log(newArr2);
无论怎么样,我们自己写的话,先把这个 map 函数实现,我们就直接函数实现就好
const arr = [1, 2, 3, 4, 5];
function map(arr, callback) {
const result = [];
for (let i = 0; i < arr.length; i++) {
const item = arr[i];
result.push(callback(item, i));
}
return result;
}
const t1 = map(arr, (e) => e * 2);
const t2 = map(arr, (e) => `<div>index${e}</div>`);
console.log(t1);
console.log(t2);
可以使用多个泛型参数:
表示输入数组中元素类型的T
,以及表述输出数组中的元素类型 U。
这个 map 函数接受的参数一个为T
类型的数组,一个为T
映射为U
的函数,最后返回一个U
类型的数组
const arr = [1, 2, 3, 4, 5];
function map<T, U>(arr: T[], callback: (e: T, i?: number) => U): U[] {
const result = [];
for (let i = 0; i < arr.length; i++) {
const item = arr[i];
result.push(callback(item, i));
}
return result;
}
const t1 = map<number, number>(arr, (e) => e * 2);
const t2 = map<number, string>(arr, (e) => `<div>index${e}</div>`);
当然,和只有一个泛型的时候一样,在调用的时候,同样可以让 Typescript 自己进行类型推导
const t1 = map(arr, (e) => e * 2);
const t2 = map(arr, (e) => `<div>index${e}</div>`);
但是注意:要么就让 Typescript 自己进行推导,要么就写全,不能想当然的写成一半,比如都同样是 number 类型,你不能想当然的认为,我在调用的时候,写一个就可以了。也就是说下面的写法是错误的:
const t1 = map<number>(arr, (e) => e * 2); // error 应该有2个类型参数,但只获得了1个
泛型的默认类型
泛型的默认类型
泛型的默认类型是一种编程语言中的特性,允许开发者在定义泛型时为其指定一个默认的类型。这意味着,如果在使用泛型时没有明确指定类型,将自动使用默认类型。
function createArray<T = number>(length: number, value: T): T[] {
return new Array(length).fill(value);
}
// 使用默认的泛型类型number
let numbers = createArray(3, 1); // 类型推断为number[]
不过这里直接给函数指定默认类型意义不大,因为函数本身就会自动的进行类型推导。
type A<T = string> = {
value: T;
};
// 使用默认泛型类型
let str: A = { value: 'Hello' }; // T默认为string
// 明确指定泛型类型为number
let num: A<number> = { value: 123 }; // 明确指定T为number
我们还可以使用之前的例子
type MyEvent<T> = {
target: T;
type: string;
};
const myEvent: MyEvent<HTMLButtonElement | null> = {
target: document.querySelector('#btn'),
type: 'click',
};
之前我们声明的这个泛型别名,在用到的时候,需要声明泛型的类型。如果大部分的 target 都是同一个类型,那么完全可以像函数的默认参数一样,给泛型一个默认类型
type MyEvent<T = HTMLElement | null> = {
target: T;
type: string;
};
const myEvent: MyEvent = {
target: document.querySelector('#myButton'),
type: 'click',
};
受限的泛型
受限的泛型
泛型确实给我们定义类型带来了方便,但是,有时候却显得约束力不太够,太过于宽泛。比如我们有时候会说,这里需要一个泛型T
,但是这个泛型T
就只能是一个对象,不应该是基本类型。甚至我们还想表达,这个泛型T
应该有一个必须要具备的特殊属性,比如length
或者value
。也就是说,应该为泛型设置一个上限
在 ES6 中,我们可以使用extends
来实现 class 类的继承,从而来表示某个类是另外一个类的子类,在 Typescript 中应用了 extends 的这个含义,表达了一个类型和另一个类型具有兼容性,从而起到约束泛型范围的效果
function getObj<T extends object>(obj: T) {
return obj;
}
getObj({ id: 1, name: 'aaa' });
type ObjLength = {
length: number;
};
function getObjLength<T extends ObjLength>(obj: T) {
return obj;
}
getObjLength({ id: 1, name: 'aaa', length: 2 });
getObjLength('aaa');
getObjLength([1, 2, 3, 4, 5]);
getObjLength({ id: 1, name: 'aaa' }); // error
如果有多个泛型,同样可以:
type ObjLength = {
length: number;
};
// 比较长度
// a > b 大于0
// a < b 小于0
// a = b 等于0
function compareLength<T extends ObjLength, U extends ObjLength>(a: T, b: U) {
return a.length - b.length;
}
const result = compareLength([1, 2, 3, 4, 5], 'abc');
console.log(result);
同样,我们可以把 extends 扩展到对象字面量类型上,其实 extends 本身就是继承的意思:
type TreeNode = {
value: string;
};
type LeafNode = TreeNode & {
isLeaf: true;
};
type InnerNode = TreeNode & {
children: TreeNode[];
};
const a: TreeNode = { value: 'a' };
const b: LeafNode = { value: 'b', isLeaf: true };
const c: LeafNode = { value: 'c', isLeaf: true };
const d: InnerNode = { value: 'e', children: [b, c] };
function mapNode<T extends TreeNode>(node: T, f: (value: string) => string): T {
return {
...node,
value: f(node.value),
};
}
const a1 = mapNode(a, (v) => v.toUpperCase());
const b1 = mapNode(b, (v) => v.toUpperCase());
const c1 = mapNode(c, (v) => v.toUpperCase());
const d1 = mapNode(d, (v) => v.toUpperCase());
console.log(a1);
console.log(b1);
console.log(c1);
console.log(d1);
如果上面的mapNode
函数不加以限制,T 的类型根本不知道node:T
中 node 是否有 value 属性,加以限制之后,我们就很安全的在函数中使用对象 node 的属性 value 了。这其实是对类型的一种细化,或者说是对类型的守卫
有时候我们想写一个类型,比如Message
,Message
可以接受一个泛型,其主要作用是从泛型上读取泛型的 “message” 属性的类型。你可能会这么写:
type Message<T> = T['message']; // error 类型“"message"”无法用于索引类型“T”
因为泛型T
并不知道message
属性是什么,这个时候,你就可以使用泛型约束
type Message<T extends { message: unknown }> = T['message'];
const person = {
id: 1,
message: 'hello',
};
type PersonMessage = Message<typeof person>; // string
元祖的类型推导
Typescript 在推导元祖的类型时会放宽要求,推导出的结果会尽量的宽泛,不在乎元祖的长度和各位置的类型,其实也就是直接会推导为数组类型
const a = [1, true]; // (number | boolean)[]
然而有时候我们希望推导的结果更严格一些,把上面例子中的变量 a 视作固定长度的元祖而不是数组。
当然,我们可以使用类型断言as const
,把元祖标记为只读的元祖的类型
const a = [1, true] as const; // readonly [1, true]
除了使用as const
断言之外,我们还可以利用 Typescript 推导剩余参数类型的方式
如果仅仅写成下面这个样子:
function tuple<T>(...ts: T[]) {
return ts;
}
const t = tuple(1, 2, 3, 4); //number[]
得到的依然还是 number 类型的属性,但是如果写成下面这个样子。
function tuple<T extends unknown[]>(...ts: T) {
return ts;
}
这个函数就完全可以替代我们声明元组的写法const tuple = [1, true];
泛型约束(T extends unknown[]
): 这里T
被约束为一个扩展自unknown[]
的类型。这意味着T
可以是unknown[]
的任何子类型,包括元组类型。
剩余参数(...ts: T
): 当使用...
操作符作为函数参数时,它在运行时表现为一个数组,但在类型层面,TypeScript 能够保留传递给函数的参数类型的精确性,这包括元素的类型和数量。因此,尽管ts
在函数体内部被当作一个数组处理,TypeScript 编译器仍然能够将其识别为T
类型,这里的T
是调用函数时根据传入参数推导出的具体元组类型
这意味着当你使用tuple
函数时,TypeScript 编译器会根据传给函数的具体参数来推导T
的具体类型,这个类型是一个元组类型,而不仅仅是一个宽泛的数组类型
const myTuple1 = tuple(1, 'hello', true); // 推导为元组类型 [number, string, boolean]
const myTuple2 = tuple(...['资料管理员', '权限管理员', '经理']); // [string, string, string]
类型理解再升级-型变
类型理解再升级-型变
我们先来看这个例子:
type User = {
id?: number;
name: string;
};
type Animal = {
id?: number;
name: string;
};
type AdminUser = {
id?: number;
name: string;
role: string;
};
function deleteUser(user: User) {
console.log(user);
}
const a1: Animal = {
id: 1,
name: 'animal1',
};
const u1: AdminUser = {
id: 2,
name: 'user2',
role: 'admin',
};
deleteUser(a1); // OK? Error?
deleteUser(u1); // OK? Error?
答案:
deleteUser(a1); 正确
deleteUser(u1); 正确
结构化类型系统
TypeScript 的类型系统特性:结构化类型系统。TypeScript 比较两个类型并非通过类型的名称,而是比较这两个类型上实际拥有的属性与方法。User 与 Animal 类型上是一致的,所以它们虽然是两个名字不同的类型,但仍然被视为结构一致,这就是结构化类型系统的特性。你可能听过结构类型的别称鸭子类型(*Duck Typing*),这个名字来源于鸭子测试(*Duck Test*)。其核心理念是,如果你看到一只鸟走起来像鸭子,游泳像鸭子,叫得也像鸭子,那么这只鸟就是鸭子。
因此:deleteUser(a1);
正确
deleteUser(u1);
为什么也是正确的?
在很多类型系统中,都有子类型与超(父)类型的概念。当然了在 java,c#这种后端名义型类型系统中子类型和父类型很容易区分,他们必须要extends
,implements
关键字。
但是在 TS 中,是通过结构进行区分的,不一定强制需要extends
,implements
关键字标注父子关系。比如上面的User
和AdminUser
。
明明u1
多了一个属性role
,这是因为,结构化类型系统认为 AdminUser
类型完全实现了 User
类型。至于额外的属性 role
,可以认为是 AdminUser
类型继承 User
类型后添加的新属性,即此时 AdminUser
类型可以被认为是 User
类型的子类型。
协变
在很多类型系统中,都有型变的概念,也就是类型变化的意思,在型变的系统中,
子类型可以赋值给父类型,叫做协变
父类型可以赋值给子类型,叫做逆变
之前的基础类型,我们一直在强调类型兼容性的问题,不同的类型当然没有兼容性可言,要谈兼容性,至少需要父子关系。至于所谓父子关系的兼容性,一般都具有下面的含义:
给定两个类型 A 和 B,假设 B 是 A 的子类型,那么在需要 A 的地方都可以放心使用 B
从上图中可以看出:
- Array 是 Object 的子类型,需要 Object 的地方都可以使用 Array
- Tuple 是 Array 的子类型,需要 Array 的地方都可以使用 Tuple
- 所有类型都是 any 的子类型,需要 any 的地方,任何类型都能用
- never 是所有类型的子类型。
- 字面量类型是对应基础类型的子类型,需要基础类型的地方都能使用字面量类型
对于结构化类型,主要的型变方式就是协变。因此,
AdminUser
是User
的子类型,那么需要User
的地方,就都可以使用AdminUser
deleteUser(u1);
是正确的,不会报错。
不过这仅仅是协变的基础形态,因为对于结构比较复杂对象来说,每一个具体的属性,都有可能还是比较复杂的形态。
type ExistUser = {
id: number;
name: string;
};
type LegacyUser = {
id?: number | string;
name: string;
};
const u2: ExistUser = {
id: 1,
name: 'user1',
};
const u3: LegacyUser = {
id: 3,
name: 'user3',
};
deleteUser(u2); // OK? Error?
deleteUser(u3); // OK? Error?
新加了两种类型,注意和之前User
类型的区别主要在id
这个属性上
User ---> id ---> number | undefined
ExistUser ---> id ---> number
LegacyUser ---> id ---> number | string | undefined
也就是说,每个类型的 id 属性的类型是不一样的,这里是联合类型,联合类型也有子类型和父类型的兼容关系。联合类型的父子关系的区分和基础类型是一样的。简单来说,越具体的,越形象化的,就是子类型
"hello" 字面量类型 比 string 类型 更具体,那么"hello"字面量类型就是 string 类型的子类型
[number, number]元组类型比数组类型更具体,那么元组类型就是数组类型的子类型
a | b
联合类型 比a | b | c
联合类型更具体,那么a | b
就是a | b | c
的子类型当然,如果你不能理解上面为啥
a | b
就是a | b | c
的子类型,你可以这么想, 你妈你去市场买水果,买梨子 | 苹果
肯定比买梨子 | 苹果 | 西瓜
更具体
因此,就id
这一个属性来说:
ExistUser < User < LegacyUser;
由于另外一个属性是一样的,所以:
ExistUser < User < LegacyUser;
那么我们就可以得出结论
deleteUser(u2); 正确
deleteUser(u3);
错误 `id`类型不兼容,不能将`number | string | undefined`赋值给`number | undefined`
不能把父类型赋值给子类型
typescript 对于结构(对象和类)的属性类型进行了协变,也就是说,如果想保证 A 对象可赋值给 B 对象,那么 A 对象的每个属性都必须是 B 对象对应属性的子类型。
如果 A 是 B 的子类型,那么我们可以说由 A 组成的复合类型(例如数组和泛型)也是 B 组成相应复合类型的子类型
type Pet = {
name: string;
};
type Dog = Pet & {
breed: string;
};
const dogs: Dog[] = [
{
name: 'Max',
breed: 'Labrador',
},
{
name: 'Rusty',
breed: 'Dalmatian',
},
];
const pets: Pet[] = dogs;
type Arrs<T> = {
arr: T[];
};
const arrs1: Arrs<Dog> = {
arr: dogs,
};
const arrs2: Arrs<Pet> = arrs1;
多余属性检查
多余属性检查
正是由于有协变这个特性,有时候又会给我们写代码增加一些困惑。
let u4: User = {
id: 1,
name: 'user3',
role: 'admin', // role 不在User类型中
};
let u5 = u1;
如果是函数中也是一样:
deleteUser(u1); // 正确
deleteUser({ id: 2, name: 'user2', role: 'admin' }); // 错误
由于有协变,我们可以把AdminUser
类型的 u1 对象看做是User
类型的子类型,然后我们可以赋值。
但是如果我们直接赋值,如果多写了属性,提示错误,这个我们也应该能理解,标注了是User
类型,但是你却多写了其他属性,TS 当然应该帮我们提示错误才对。而且对于这种和标注类型不匹配的检查,在 TS 中就叫做多余属性检查。
只要将一个直接字面量赋值给变量、方法参数或者构造函数参数,就会触发多余属性检查
当然,就算直接赋值一个字面量对象,你能自己确定是什么类型,直接用类型断言处理一下也可以。
deleteUser({ id: 2, name: 'user2', role: 'admin' } as User);
逆变
class 也是结构化类型
先来看一下讲过的协变内容:
type Animal = {
eat(): void;
};
type Pet = Animal & {
run(): void;
};
type Dog = Pet & {
bark(): void;
};
Animal、Pet 和 Dog,很明显具有逐层的父子关系
let a: Animal = {
eat() {
console.log('eat');
},
};
let p: Pet = {
eat() {
console.log('eat');
},
run() {
console.log('run');
},
};
let d: Dog = {
eat() {
console.log('eat');
},
run() {
console.log('run');
},
bark() {
console.log('bark');
},
};
function feed(pet: Pet) {
pet.run();
return pet;
}
feed(a); // Error
feed(p);
feed(d);
顺便提一句,使用 class 类也是一样的,关于类的基本使用,和 Javascript 是一样的。而且父子层级关系也是一样。关于类型的一些问题,我们在后面再慢慢解释
class Animal {
eat() {
console.log('eat');
}
}
class Pet extends Animal {
run() {
console.log('run');
}
}
class Dog extends Pet {
bark() {
console.log('bark');
}
}
let a = new Animal();
let p = new Pet();
let d = new Dog();
function feed(pet: Pet) {
pet.run();
return pet;
}
feed(a); // Error
feed(p);
feed(d);
无论怎么样,关于协变的内容和之前是一样的,因为class 类也是结构化类型,子类型的值是可以传递到需要父类型的地方。
逆变
但是,如果是函数呢?
function clone(f: (p: Pet) => Pet): void {
// ...
}
现在有不同的函数
function petToPet(p: Pet): Pet {
return new Pet();
}
function petToDog(p: Pet): Dog {
return new Dog();
}
function petToAnimal(p: Pet): Animal {
return new Animal();
}
将函数传递到clone
函数中
clone(petToPet);
clone(petToDog);
clone(petToAnimal); // error "类型“(p: Pet) => Animal”的参数不能赋给类型“(p: Pet) => Pet”的参数
petToDog
可以传递过去,但是 petToAnimal 却报错了。为什么呢?我们用伪代码模拟一下:
function clone(f: (p: Pet) => Pet): void {
// 伪代码
let parent = new Pet();
let child = f(parent);
child.run(); // 不安全
}
如果传给clone
函数的f
返回的是Animal
,那就不能调用.run
方法。所以在编译时,Typescript
会确保传入的函数至少返回一个Pet
由此可以推断:函数和函数之间,在其他都一致的情况下,如果一个函数的返回类型是另一个函数返回类型的子类型,那么函数的返回类型是协变的
那么函数的参数类型呢?
function petToPet(p: Pet): Pet {
return new Pet();
}
function animalToPet(a: Animal): Pet {
return new Pet();
}
function dogToPet(d: Dog): Pet {
return new Pet();
}
将函数传递到clone
中
clone(petToPet);
clone(animalToPet);
clone(dogToPet); // Error "类型“(d: Dog) => Pet”的参数不能赋给类型“(p: Pet) => Pet”的参数
animalToPet
传递过去可以,但是dogToPet
却报错了,我们还是可以通过伪代码分析一下:
function dogToPet(d: Dog): Pet {
d.bark(); // 调用子类型的特有函数
return new Pet();
}
现在把dogToPet
传递给clone
,如果clone
函数中是 Pet 的实例,那么这就是不安全的。因为.bark()
只在Dog
中定义了,不是所有的Pet
都定义
也就是说:函数和函数之间,函数的参数个数一致的情况下,函数的参数是逆变的,也就是函数参数的父类型可以赋值给子类型
总结来说,在不考虑this
的情况下,满足以下条件,可以说函数 A 是函数 B 的子类型
1、函数 A 的参数数量小于或等于函数 B 的参数数量
2、函数 A 的返回类型是函数 B 返回类型的子类型,也就是协变的
3、函数 A 的各个参数的类型是函数 B 相应参数的父类型,参数是逆变的
考虑到历史遗留问题,Typescript 中的函数其实默认会对参数和 this 类型做协变,这样并不安全,因此
strict
家族就有strictFunctionTypes
,默认打开strict:true
。当然也会打开strictFunctionTypes:true