1. 函数声明与调用

函数我们之前一直在使用了,基本上和Javascript声明函数是一样的,只是在Typescript中我们需要现实的注解函数的参数

1
2
3
function add(a: number, b: number): number{
return a + b;
}

函数的返回类型Typescript可以自己推导出来,不过也可以显示的注解

当然对于函数的声明也和Javascript一样,无论是具名函数,还是函数表达式,还是箭头函数,都没有任何问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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将检查实参是否与函数的形参类型兼容

1
const sum = add(1, 2);

返回的结果也不需要你纠结,类型是固定的。

如果忘记传入某个参数,或者传入的参数类型有误,Typescript将指出问题

1
2
3
const sum = add(1); //error 应有2个参数,但获得1个
const str = sayHello1(123); // error 类型“123”的参数不能赋给类型“string”的参数

1.1 可选参数与默认参数

同样可以使用可选符号?把参数标记为可选。

声明函数的参数时,必要的参数放在前面,然后才是可选参数

1
2
3
4
function sendMessage(userId: number, message?: string) { 
console.log(userId, message || "");
}
sendMessage(1);

与在Javascript中一样,可以为可选参数提供默认值,这样做在语义上与把参数标记为可选是一样的。

带默认值的参数调用时可以不用传入参数的值,并且带默认值的参数不要求一定要放在参数列表的末尾,可选参数是必须放在末尾。不过默认参数放在前面也没有任何意义,约定俗成我们都还是放在末尾。

1
2
3
function sendMessage(userId: number, message="hello") { 
console.log(userId, message || "");
}

1.2 剩余参数

函数有时候接受参数的数量是不定的,如果不想让参数固定,在以前,我们可以使用arguments这个隐藏参数搞定

1
2
3
4
function sum() { 
return Array.from(arguments).reduce((result, item) => result + item, 0);
}
sum(1, 2, 3, 4, 5); //error

使用arguments的坏处显而易见,就算我们之前写Javascript,你也不会使用arguments去处理这些问题。在Typescript更加如此。

  1. arguments是一个伪数组,使用的时候还需要转换才能使用数组相关方法
  2. 在调用的时候,会明确的提醒你,这个函数不需要参数,但是你却给了参数
  3. 还有一个隐藏的点是,reduce函数调用的回调函数中,所有的参数全是any

要确保安全可靠,我们当然应该使用剩余参数(rest parameter)

1
2
3
4
function sum(...numbers:number[]) { 
return numbers.reduce((result, item) => result + item, 0);
}
sum(1, 2, 3, 4, 5);

使用剩余参数,上面所有的问题都解决了:

  1. 剩余参数是一个数组,并且我们可以使用Typescript定义具体的类型
  2. 调用函数的时候,也会有明确的提示
  3. reduce回调函数中的参数全部都有具体的类型

当然,唯一需要注意一点是:

一个函数最多只能有一个剩余参数,而且必须位于参数列表的最后

1.3 this的类型注解

在Javascript中,我们可能会写出下面的代码(可以在浏览器上测试)

1
2
3
function showDate() { 
return `${this.getFullYear()}-${this.getMonth() + 1}-${this.getDate()}`
}

这个函数里面的this,其实很明显是为了获取Date对象,所以,我们调用的时候,会使用call方法绑定具体的Date对象

1
2
3
4
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对象

1
2
3
4
5
6
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家族的一员

2. 调用签名

我们现在在讲函数,那么我们声明的函数到底是什么类型呢?就是一个Function类型吗?

其实,和我们之前讲的对象类型一样,object能描述所有对象,function也可以描述所有函数,但是并不能体现函数的具体类型

1
2
3
function add(a: number, b: number) { 
return a + b;
}

像上面这样的函数,我们可以用Typescript进行描述

1
(a: number, b: number) => number

这是Typescript表示函数类型的句法,也称为调用签名

函数的调用签名只包含类型层面的代码,即只有类型,没有值。因此,函数的调用签名可以表示参数的类型、this的类型、返回值的类型。剩余参数的类型和可选参数的类型,但是无法表示默认值(因为默认值是值,不是类型)。调用签名没有函数体,无法推导出返回类型,所以必须显式的注解

函数签名其实对我们写函数也有指导意义。

1
2
3
4
5
6
7
8
9
10
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);
}

我们可以把调用签名和函数表达式结合起来

1
2
3
4
type Log = (userId: number, message?: string) => void;
const log: Log = (userId, message) => {
console.log(userId, message);
}

将函数表达式注解为Log类型后,你会发现,不必再此注解参数的类型,因为在定义Log类型的时候已经注解了message和userId的类型。Typescript能从Log中推导出来,同理,返回值类型其实也是一样(当然返回值类型本身就能帮我们进行推导)

上面使用的都是类型别名,当然也能使用interface

1
2
3
4
5
6
7
8
interface Log {
(userId: number, message?: string): void;
}

// type Log = (userId: number, message?: string) => void;
const log: Log = (userId, message) => {
console.log(userId, message);
}

其实,如果我们已经有具体的函数了,完全可以通过typeof获取函数的类型声明

1
2
3
4
5
6
7
8
9
10
11
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;

如果是有回调函数,一样添加回调函数相关声明就行了

1
2
3
4
5
6
7
8
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的声明再提取成一个类型别名

1
2
3
4
5
6
// 注意:没有用模块化,类型别名的命名容易和全局变量冲突, 比如ErrorCallback
type ErrorCB = (err: Error | null, result: string) => void;

function handleData(data: string, callback:ErrorCB):void {
......
}

当然也能够整个函数提取为类型别名或者接口,交给函数表达式处理:

1
2
3
4
5
6
7
8
9
10
11
12
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直接获取已有函数的类型声明:

1
type HandleData = typeof handleData;

在对象字面量类型中声明函数类型,和普通的函数类型声明没有什么差别:

1
2
3
4
5
6
type User = {
name: string;
id: number;
show: (name: string) => void;
info(id: number): string;
}

2.1 上下文类型推导

直接把函数类型进行声明,Typescript能从上下文中推导出参数的类型。这是Typescript类型推导的一个强大特性,我们一般称为上下文类型推导

上下文类型推导,在某些时候非常的有用,比如回调函数中

1
2
3
4
5
6
7
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)

按照正常情况,既然是一个函数,那么函数中传递的参数,就应该声明类型。

但是,这里其实我们可以省略,直接简写为:

1
times(n => console.log(n), 4);

因为Typescript能从上下文推导出n是一个数字,因为在times的签名中,我们声明回调函数f的参数index是一个数字。那么Typescript就能推导出,下面传入的回调函数中的参数n,就是那个参数,该参数的类型必然应该是number类型

但是,也有需要注意的地方:

如果回调函数的声明不是在行内直接声明,那么Typescript无法推导出它的类型

1
2
const fn = (n) => console.log(n); // error 参数n隐式具有“any”类型
times(fn, 4);

这个错误当然也很好理解,外部直接声明的fn相当于是一个全新的函数了,在声明的时候和times函数是没有任何关联的,当然不可能进行上下文类型推导

3. 重载

在某些逻辑较复杂的情况下,函数可能有多组入参类型和返回值类型.

比如有这样简单的需求:函数有两个参数,要求两个参数如果都是number类型,那么就做乘法操作,返回计算结果,如果两个参数都是字符串,就做字符串拼接,并返回字符串拼接结果。其他情况直接抛出异常:参数类型必须相同

首先,很多初学者的直观想法是,我直接声明两个不同的函数不就完了

首先这种做法不是我们要讲的这个概念。另外,其实我们做的事情是一样的,比如console.log()函数,我们可以传递number,string,boolean甚至对象,都能实现打印,但是使用用到的都是一个console.log()函数,如果不同的参数,对应不同的函数名,那这样对于使用者来说,是极大的心智负担。

根据这样的做法,我们很容易写出下面的代码:

1
2
3
4
5
6
7
8
9
10
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);

这个代码,咋看没有任何问题,但实际上隐含了很多问题在里面。

第一个,这样的代码实际并没有起到类型约束的效果,我们要类型系统,目的就是要在编译期间就帮我们提示错误,避免运行时错误,然后再回来调试。而现在这个代码的问题:

  1. 参数可以输number也可以输入string,并没有在编译时就给我提示不能输入不同类型的参数
  2. 返回值类型并不固定,两个参数是number,那么返回的类型,就应该一定是number,但是现在返回的是string | number

第二个,是类型编程语言的常识问题:在很多静态语言中,一旦指定了特定的参数和返回类型,就只能使用相应的参数调用函数,而且返回值的类型始终如一。而我们已经习惯了Javascript的写法,了解了一点点Typescript语法,就觉得上面应该是没问题的啊,有类型的限定,有函数的自动推导。

其实,在很多静态语言中,上面的写法根本不成立,要么参数指定是数值类型,要么参数固定是字符串类型,也没有所谓的推导,函数返回值类型也必须指定,比如下面的伪代码

1
2
3
4
5
6
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)**来解决这个问题

1
2
3
4
5
6
7
8
9
10
11
12
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类型

1
2
3
4
function changeType(x: string | number): number | string { 
return typeof x === 'string' ? parseInt(x, 10) : x.toString();
}
changeType("1")

这样写代码依然和之前的问题一样,不能在编译时提供帮助,因此加上重载签名

1
2
3
4
5
6
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 APIcreateElement函数的处理,这个函数大家都用过,参数传递具体的标签名字符串,就帮我们创建对应的HTML元素

1
2
3
4
5
6
7
8
9
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

1
2
3
4
5
6
7
8
9
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 等语言中,重载体现在多个名称一致但入参不同的函数实现上,这才是更广义上的函数重载

4. 理解泛型

前面讲解了这么多,突然有一天有一个很简单的需求摆在你的面前,让你用Typescript封装一个函数,传入任意类型的参数,传入什么类型就返回什么类型…

对于Javascript来说,这有什么难度?甚至用箭头函数写更加简单

1
2
3
4
function identity(value){
return value;
}
const identity = value => value;

但是对于Typescript来说,就有问题了,参数要有约束,返回的类型也应该和参数是同一个类型,那这该怎么办?

难道传入number?传入string

1
2
3
function identity(value:number){
return value;
}

但是这样不是限定死了,就只能是参数限定的类型吗?

既然要任何类型,首先你会想到用any

1
2
3
4
5
function identity(value:any){
return value;
}

const s = identity("hello");

但是用了any和直接写Javascript有什么区别呢?这就丧失了类型检查的效果。就算现在不报错了,最后得到的变量其实也不知道到底是什么类型,比如上面代码中的变量,并没有调用字符串的相关方法的提示。这就完全丧失了使用Typescript的意义。

当然这个时候,我们就会想到前面刚刚讲的函数重载

1
2
3
4
5
6
7
8
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个基本类型啊,如果还有不同类型的数组呢?不同类型的数组都还好说,无非就继续往上加类型而已

1
2
3
4
5
6
7
8
9
10
11
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"]);

但是如果还有对象类型呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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的自动推导的功能,我不确定当前用什么类型,当用到什么类型的参数的时候,根据我传给你的类型进行推导,只要在我代码用到了推导的类型,那么就都是这个类型,这就是我们要讲的泛型

1
2
3
function identity<T>(value: T): T{ 
return value;
}

在函数名后,使用尖括号<>来声明泛型T,表示传递的泛型的类型,你可以把他先理解为一种占位符号。后面凡是出现一样的这个符号T,那就表示是一样的类型。

本质上T其实和我们写的number,string等等是一个意思。当我们调用时:

1
2
3
4
5
6
7
8
9
10
11
12
13
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其实可以根据我们传入的参数自动推导泛型的类型,所以,调用的时候,前面的<>是可以省略的。

1
2
3
4
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开始,依次使用UVW等。

就算是多个单词AbbAcc也没有问题,因为泛型字母就表示一个占位符,类型检查器将根据上下文填充具体的类型

不过一般TEKVU等字母用的比较多而已

再来一个例子:

1
2
3
4
function getTuple<T>(a: T, b: T){ 
return [a, b]
}
const as = getTuple<string>("hello", "world");

我们之前还写过这样的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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);

编译之后,你会发现myNumberFilterfilter这两个函数的内部逻辑是一模一样的。

这其实更进一步验证了我们之前一直讲的,类型系统和逻辑是分离的,我们在编译时把类型修改成什么样,并不会对运行时的逻辑产生任何影响。

但是,泛型让函数的功能,在编译时更具有一般性,比接受具体类型的函数更加强大。

泛型可以理解为一种约束。

当我们把函数的参数注解为fn(n:number)的时候,参数n的值就被约束为了number类型。

同样,泛型TT所在位置的类型约束为T所绑定的类型

image.png

当然,上面的函数是函数声明的写法,函数表达式写法也一样:

1
2
3
const filter = <T>(array: T[], callback: (value: T, index?: number) => boolean): T[] => { 
......
}

由于泛型本身十分的方便好用,所以在数组,对象,类型别名,以及后面要讲解的类和接口中都可以使用泛型。

只要有可能就应该使用泛型,这样写出来的代码更具有一般性,可以重复使用,并且简明扼要

5. 类型别名与接口使用泛型

数组,类型别名,类和接口中都能使用泛型

数组使用泛型:

1
2
3
4
5
6
7
8
9
10
11
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里面到底有什么是确定不了的,如果我们希望把返回内容封装成一个类型别名,那么就需要使用泛型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type ResultData<T> = {
message: string,
code: number,
data: T
}

type User = {
id: number,
name: string
tel: string
address?: string
}

type UserData = ResultData<User>;

当然泛型别名之间也能互相调用,函数中也可以调用泛型别名

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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"
});

当然,接口中使用泛型和类型别名没啥区别。上面的代码直接将类型别名改为接口就直接使用了。

1
2
3
4
5
6
7
8
9
10
interface MyEvent<T> {
target: T
type: string
}

interface TimedEvent<T> {
event: MyEvent<T>
from: Date
to: Date
}

有时候还可以利用泛型别名给我们写TS代码带来一些方便,比如在Typescript是strict声明下,是不能给一个已经固定类型的定义为null值的,如果我们平时写Javascript的时候习惯了定义某些值的时候给一个null,那Typescript的这种限定就稍稍觉得有那么一点不习惯

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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函数,我们可以直接用调用签名进行约束

1
2
3
4
5
6
7
8
9
10
11
12
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);

上面是之前函数声明的写法

1
type Filter<T> = (arr: T[], callback: (item: T, index?: number) => boolean) => T[];

现在写成了调用签名,相当于要直接约束某个函数的写法了,所以具体在写函数的时候,就需要传递具体的类型了

1
2
3
4
5
6
7
8
9
10
11
12
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;
}

不要混淆类型操作

1
2
3
4
5
6
7
8
// 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;

6. 多泛型

考虑一个简单问题:创建一个通用的交换函数,它接受一个包含两个元素的数组,并返回元素交换位置后的数组

1
2
3
4
5
6
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函数,简单来说,给我一个数组,然后根据回调函数,我们能组装成另外一个新的数组,而这个新的数组,可能类型和原来的数组一样,也有可能不一样

1
2
3
4
5
6
// 原生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函数实现,我们就直接函数实现就好

1
2
3
4
5
6
7
8
9
10
11
12
13
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类型的数组

1
2
3
4
5
6
7
8
9
10
11
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自己进行类型推导

1
2
const t1 = map(arr, (e) => e * 2);
const t2 = map(arr, (e) => `<div>index${e}</div>`);

但是注意:要么就让Typescript自己进行推导,要么就写全,不能想当然的写成一半,比如都同样是number类型,你不能想当然的认为,我在调用的时候,写一个就可以了。也就是说下面的写法是错误的:

1
const t1 = map<number>(arr, (e) => e * 2); // error 应该有2个类型参数,但只获得了1个

7. 泛型的默认类型

泛型的默认类型是一种编程语言中的特性,允许开发者在定义泛型时为其指定一个默认的类型。这意味着,如果在使用泛型时没有明确指定类型,将自动使用默认类型。

1
2
3
4
5
6
function createArray<T = number>(length: number, value: T): T[] {
return new Array(length).fill(value);
}

// 使用默认的泛型类型number
let numbers = createArray(3, 1); // 类型推断为number[]

不过这里直接给函数指定默认类型意义不大,因为函数本身就会自动的进行类型推导。

1
2
3
4
5
6
7
8
9
type A<T = string> = {
value: T;
};

// 使用默认泛型类型
let str: A = { value: "Hello" }; // T默认为string

// 明确指定泛型类型为number
let num: A<number> = { value: 123 }; // 明确指定T为number

我们还可以使用之前的例子

1
2
3
4
5
6
7
8
type MyEvent<T> = {
target: T
type: string
}
const myEvent: MyEvent<HTMLButtonElement | null> = {
target: document.querySelector("#btn"),
type: "click"
}

之前我们声明的这个泛型别名,在用到的时候,需要声明泛型的类型。如果大部分的target都是同一个类型,那么完全可以像函数的默认参数一样,给泛型一个默认类型

1
2
3
4
5
6
7
8
type MyEvent<T = HTMLElement | null> = {
target: T
type: string
}
const myEvent: MyEvent = {
target: document.querySelector("#myButton"),
type: "click"
}

8. 受限的泛型

泛型确实给我们定义类型带来了方便,但是,有时候却显得约束力不太够,太过于宽泛。比如我们有时候会说,这里需要一个泛型T,但是这个泛型T就只能是一个对象,不应该是基本类型。甚至我们还想表达,这个泛型T应该有一个必须要具备的特殊属性,比如length或者value。也就是说,应该为泛型设置一个上限

在ES6中,我们可以使用extends来实现class类的继承,从而来表示某个类是另外一个类的子类,在Typescript中应用了extends的这个含义,表达了一个类型和另一个类型具有兼容性,从而起到约束泛型范围的效果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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

如果有多个泛型,同样可以:

1
2
3
4
5
6
7
8
9
10
11
12
13
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本身就是继承的意思:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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了。这其实是对类型的一种细化,或者说是对类型的守卫

有时候我们想写一个类型,比如MessageMessage 可以接受一个泛型,其主要作用是从泛型上读取泛型的 “message” 属性的类型。你可能会这么写:

1
type Message<T> = T['message'] // error 类型“"message"”无法用于索引类型“T”

因为泛型T并不知道message属性是什么,这个时候,你就可以使用泛型约束

1
2
3
4
5
6
7
8
type Message<T extends { message: unknown }> = T['message']

const person = {
id:1,
message:"hello"
}

type PersonMessage = Message<typeof person> // string

8.1 元祖的类型推导

Typescript在推导元祖的类型时会放宽要求,推导出的结果会尽量的宽泛,不在乎元祖的长度和各位置的类型,其实也就是直接会推导为数组类型

1
const a = [1, true]; // (number | boolean)[]

然而有时候我们希望推导的结果更严格一些,把上面例子中的变量a视作固定长度的元祖而不是数组。

当然,我们可以使用类型断言as const,把元祖标记为只读的元祖的类型

1
const a = [1, true] as const; // readonly [1, true]

除了使用as const断言之外,我们还可以利用Typescript推导剩余参数类型的方式

如果仅仅写成下面这个样子:

1
2
3
4
5
function tuple<T>(...ts: T[]) { 
return ts;
}

const t = tuple(1, 2, 3, 4); //number[]

得到的依然还是number类型的属性,但是如果写成下面这个样子。

1
2
3
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的具体类型,这个类型是一个元组类型,而不仅仅是一个宽泛的数组类型

1
2
const myTuple1 = tuple(1, 'hello', true); // 推导为元组类型 [number, string, boolean]
const myTuple2 = tuple(...["资料管理员","权限管理员","经理"]); // [string, string, string]

9. 类型理解再升级-型变

我们先来看这个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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); 正确

9.1 结构化类型系统

TypeScript 的类型系统特性:结构化类型系统。TypeScript 比较两个类型并非通过类型的名称,而是比较这两个类型上实际拥有的属性与方法。User 与 Animal 类型上是一致的,所以它们虽然是两个名字不同的类型,但仍然被视为结构一致,这就是结构化类型系统的特性。你可能听过结构类型的别称鸭子类型(*Duck Typing*),这个名字来源于鸭子测试(*Duck Test*)。其核心理念是,如果你看到一只鸟走起来像鸭子,游泳像鸭子,叫得也像鸭子,那么这只鸟就是鸭子

因此:deleteUser(a1);正确

deleteUser(u1);为什么也是正确的?

在很多类型系统中,都有子类型与超(父)类型的概念。当然了在java,c#这种后端名义型类型系统中子类型和父类型很容易区分,他们必须要extendsimplements关键字。

但是在TS中,是通过结构进行区分的,不一定强制需要extendsimplements关键字标注父子关系。比如上面的UserAdminUser

明明u1多了一个属性role,这是因为,结构化类型系统认为 AdminUser 类型完全实现了 User 类型。至于额外的属性 role,可以认为是 AdminUser 类型继承 User 类型后添加的新属性,即此时 AdminUser 类型可以被认为是 User 类型的子类型。

9.2 协变

在很多类型系统中,都有型变的概念,也就是类型变化的意思,在型变的系统中,

子类型可以赋值给父类型,叫做协变

父类型可以赋值给子类型,叫做逆变

之前的基础类型,我们一直在强调类型兼容性的问题,不同的类型当然没有兼容性可言,要谈兼容性,至少需要父子关系。至于所谓父子关系的兼容性,一般都具有下面的含义:

给定两个类型A和B,假设B是A的子类型,那么在需要A的地方都可以放心使用B

image.png

从上图中可以看出:

  • Array是Object的子类型,需要Object的地方都可以使用Array
  • Tuple是Array的子类型,需要Array的地方都可以使用Tuple
  • 所有类型都是any的子类型,需要any的地方,任何类型都能用
  • never是所有类型的子类型。
  • 字面量类型是对应基础类型的子类型,需要基础类型的地方都能使用字面量类型

对于结构化类型,主要的型变方式就是协变。因此,

  • AdminUserUser的子类型,那么需要User的地方,就都可以使用AdminUser

deleteUser(u1);是正确的,不会报错。

不过这仅仅是协变的基础形态,因为对于结构比较复杂对象来说,每一个具体的属性,都有可能还是比较复杂的形态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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这个属性上

1
2
3
4
5
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这一个属性来说:

1
ExistUser < User < LegacyUser

由于另外一个属性是一样的,所以:

1
ExistUser < User < LegacyUser

那么我们就可以得出结论

1
2
3
4
5
6
deleteUser(u2); 正确

deleteUser(u3);

错误 `id`类型不兼容,不能将`number | string | undefined`赋值给`number | undefined`
不能把父类型赋值给子类型

typescript对于结构(对象和类)的属性类型进行了协变,也就是说,如果想保证A对象可赋值给B对象,那么A对象的每个属性都必须是B对象对应属性的子类型

如果A是B的子类型,那么我们可以说由A组成的复合类型(例如数组和泛型)也是B组成相应复合类型的子类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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;

10. 多余属性检查

正是由于有协变这个特性,有时候又会给我们写代码增加一些困惑。

1
2
3
4
5
6
7
let u4: User = {
id:1,
name: 'user3',
role: 'admin' // role 不在User类型中
}

let u5 = u1;

如果是函数中也是一样:

1
2
deleteUser(u1);  // 正确
deleteUser({ id: 2, name: 'user2', role: 'admin' }); // 错误

由于有协变,我们可以把AdminUser类型的u1对象看做是User类型的子类型,然后我们可以赋值。

但是如果我们直接赋值,如果多写了属性,提示错误,这个我们也应该能理解,标注了是User类型,但是你却多写了其他属性,TS当然应该帮我们提示错误才对。而且对于这种和标注类型不匹配的检查,在TS中就叫做多余属性检查

只要将一个直接字面量赋值给变量、方法参数或者构造函数参数,就会触发多余属性检查

当然,就算直接赋值一个字面量对象,你能自己确定是什么类型,直接用类型断言处理一下也可以。

1
deleteUser({ id: 2, name: 'user2', role: 'admin' } as User);

11. class也是结构化类型

先来看一下讲过的协变内容:

1
2
3
4
5
6
7
8
9
type Animal = {
eat(): void;
}
type Pet = Animal & {
run(): void;
}
type Dog = Pet & {
bark(): void;
}

Animal、Pet和Dog,很明显具有逐层的父子关系

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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是一样的。而且父子层级关系也是一样。关于类型的一些问题,我们在后面再慢慢解释

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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类也是结构化类型,子类型的值是可以传递到需要父类型的地方。

12. 逆变

但是,如果是函数呢?

1
2
3
function clone(f: (p: Pet) => Pet): void { 
// ...
}

现在有不同的函数

1
2
3
4
5
6
7
8
9
10
11
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函数中

1
2
3
clone(petToPet);
clone(petToDog);
clone(petToAnimal); // error "类型“(p: Pet) => Animal”的参数不能赋给类型“(p: Pet) => Pet”的参数

petToDog可以传递过去,但是petToAnimal却报错了。为什么呢?我们用伪代码模拟一下:

1
2
3
4
5
6
function clone(f: (p: Pet) => Pet): void { 
// 伪代码
let parent = new Pet();
let child = f(parent);
child.run(); // 不安全
}

如果传给clone函数的f返回的是Animal,那就不能调用.run方法。所以在编译时,Typescript会确保传入的函数至少返回一个Pet

由此可以推断:函数和函数之间,在其他都一致的情况下,如果一个函数的返回类型是另一个函数返回类型的子类型,那么函数的返回类型是协变的

那么函数的参数类型呢?

1
2
3
4
5
6
7
8
9
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

1
2
3
clone(petToPet);
clone(animalToPet);
clone(dogToPet); // Error "类型“(d: Dog) => Pet”的参数不能赋给类型“(p: Pet) => Pet”的参数

animalToPet传递过去可以,但是dogToPet却报错了,我们还是可以通过伪代码分析一下:

1
2
3
4
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