🍚 深入了解TypeScript类型
1. any与unknown
1.1 any
在TS中,编译时一切都要有类型,如果你和TS类型检查器无法确定类型是什么,默认为any
。这是兜底的类型,是TS中所有类型的教父。
1 | let a: any = 666; |
正常情况下,第三个语句应该在TS中报错才对(谁会去计算一个数字和一个数组之和呢?)
但是如果显示声明了any标注,就不会报错,其实这里的做法就和原生JS的处理一模一样了。
换句话说,如果要使用any,一定要显示标注,如果TS推导出值的类型为any(例如忘记注解函数的参数,或者引入没有类型的JavaScript模块),将抛出运行时异常。
1 | let foo; // any |
默认情况下,Typescript是宽容的,在推导出类型为any时其实不会报错,如果在
tsconfig.json
中启用了noImplcitAny
标志,就会遇到隐式any类型时报错。
noImplcitAny
隶属于TSC的strict
标志家族,如果已经在tsconfig.json
中启用了strict
,那就不需要专门设置noImplcitAny
标志了,效果是一样的。
有时候我们可能确实需要一个表示任意类型的变量,特别是从javascript代码移植到typescript的时候。比较明显的比如console.log()
方法就能接收任意类型的参数。
当然默认情况下,你看到的应该是这样的
1 | log(...data: any[]): void; |
我们现在能看到类型提示,这是由于VS Code编辑器结合着lib.dom.d.ts
文件提供的TS支持。
如果已经安装了@types/node
,可以得到nodejs对于console.log
函数更加细致的提示:
1 | log(message?: any, ...optionalParams: any[]): void; |
关于@types的内容,我们在🥬 TypeScript快速入门 中已经说过。
Node.js 的核心模块和某些第三方模块并不是天然支持Typescript的。这就意味着,如果在 TypeScript 项目中使用这些模块时,编译器无法得知这些模块的类型信息,从而无法提供类型检查和自动补全的功能。比如下面的代码会报错:
1 | const fs = require('fs'); // error 找不到名称require,需要Nodejs类型定义 |
我们可以手动安装nodejs的TypeScript 社区DefinitelyTyped 提供的声明文件库。当使用 TypeScript 开发 Node.js 项目时,
@types/node
库可以为 Node.js 的核心模块和常用的第三方模块提供类型定义,以便在开发过程中获得类型检查和自动补全的支持。
1 | npm i @types/node -D |
这样上面代码
const fs = require('fs');
也找到的对应的类型支持,在TS文件中不会再报错了。
总的来说,你可以在 any 类型变量上任意地进行操作,包括赋值、访问、方法调用等等,此时可以认为类型推导与检查是被完全禁用的:
1 | let anyVar: any = null; |
正如我们一开始就强调的 【any兜底的类型,是TS中所有类型的教父】
any能兼容所有类型,也能够被所有类型兼容
这一作用其实也意味着类型世界给你开了一个外挂,无论什么时候,你都可以使用 any 类型跳过类型检查。当然,运行时出了问题就需要你自己负责了。
any 类型的万能性也导致我们经常滥用它,比如类型不兼容了就 any 一下,类型不想写了也 any 一下,不确定可能会是啥类型还是 any 一下。此时的 TypeScript
就变成了令人诟病的 AnyScript
。
1.2 unknown
少数情况下,如果确实无法预知一个值的类型,不要使用any,更合理的方式是使用 unknown
unknown也表示任何值,一个 unknown 类型的变量可以再次赋值为任意其它类型,但只能赋值给 any 与 unknown 类型的变量
1 | let a: unknown = 30; |
- TS不会把任何值推导为
unknown
类型,必须显式注解 unknown
类型的值可以比较unknown
类型的变量可以赋值给any
或者unknown
类型的其他变量- 但是执行操作时不能假定
unknown
类型的值为某种特定的类型(比如上面的运算,注意和any的区别),必须先向TS证明一个值确实是某个类型,可以使用typeof
简单的说,any 放弃了所有的类型检查,而 unknown 并没有。
1 | let anyFn:any; |
在类型未知的情况下,更推荐使用 unknown 标注。这相当于你使用额外的心智负担保证了类型在各处的结构,后续重构为具体类型时也可以获得最初始的类型信息,同时还保证了类型检查的存在。当然,unknown 用起来很麻烦。
2. boolean与类型字面量
number
,boolean
,string
,symbol
,bigint
这些js本身就支持的基础类型使用起来很简单,ts的书写几乎感觉不到和js的差别,而且支持很多种书写的方式,当然中间还隐藏着一些很重要的细节。拿boolean举例来说:
1 | let a = true; |
- 可以让TS推导出值的类型为boolean(a,b)
- 可以明确的告诉TS,值的类型为boolean(d)
- 可以明确的告诉TS,值为某个具体的boolean值(e,f和g)
- 可以让TS推导出(const)值为某个具体的布尔值(c)
首先我们常见的写法是1-4(行),要么使用TS自己的类型推导,要么我们自己定义好boolean类型,这是我们开始就介绍的方式。但是,5-7(行)的写法是什么意思?
其实写法也很直观,我们大概也能猜到,变量e和f不是普通的boolean类型,而是值只为true和false的boolean类型
把类型设为某个值,就限制了e和f在所有布尔值中只能取指定的那个值。这个特性称为类型字面量(type literal)
类型字面量:仅仅表示一个值的类型
由于类型字面已经限定了具体的类型true或者false,因此上面代码第7行的错误就可以理解了:
1 | let g: true = false; // error 不能将类型false分配给类型true |
特别注意一下第三行的代码:const c = true;
,这里的变量c的类型是类型字面量true。
因为const声明的基本类型的值,赋值之后便无法修改,因此TS推导出的是范围最窄的类型
3. number与bigint
有了上面boolean类型的说明,其他的基本数据类型基本一致
bigint是ES11(ES2020)新增的一种基本数据类型,在JS中,可以用 Number 表示的最大整数为 2^53 - 1,可以写为 Number.MAX_SAFE_INTEGER。如果超过了这个界限,那么就可以用 BigInt 来表示,它可以表示任意大的整数。
在一个整数字面量后面加 n 的方式定义一个 bigint,或者调用函数 BigInt()
注意这里强调的问题:ES11(ES2020),如果编译的时候没有指定tsconfig的target(指定代码编译成的版本)和lib(TSC假定运行代码的环境)为es2020以上的版本,或者执行tsc的时候,没有指定–target为es2020以上版本,将会编译报错
1 | let a = 123; |
- 可以让TS推导出值的类型为number/bigint(a,b,a1,b1)
- 可以明确的告诉TS,值的类型为number/bigint(e,f1)
- 可以明确的告诉TS,值为某个具体的number/bigint值(e,f,g,g1,h1)
- 可以让TS推导出(const)值为某个具体的number/bigint值(c,b2)
4. string
与boolean和number形式是一样的,而且string字符串形式同样有单引号''
,双引号""
和模板字符串``的形式
模板字符串还可以有其他的作用,这个在后期再给大家介绍
5. symbol
symbol 符号是ES6新增的一种基本数据类型。
注意:如果编译的时候没有指定tsconfig的target和lib为es6(ES2015)以上的版本,或者执行tsc的时候,没有指定–target为es2015以上版本,将会编译报错
symbol经常用于代替对象和映射的字符串键,确保使用正确的键,以防键被意外设置。
1 | let a = Symbol('a'); |
Symbol(‘a’)使用指定的名称新建了一个符号,这个符号是唯一的,不与其他任何符号相等,即便再使用相同的名称创建一个符号也是如此。
symbol 属性不参与
for..in
循环。Object.keys()
也会忽略他们
当然 symbol也能进行全局注册:
1 | let id1 = Symbol.for('id') |
Symbol.for()
方法创建前,会首先搜索 全局符号注册表 ,看看是否存在一个键值为 id
的 符号值 。如果存在就会返回已存在的 符号值 ;否则创建一个新的 符号值
但是,如果使用const声明的symbol将会是unique symbol
类型
1 | const c = Symbol('a'); // typeof c |
unique symbol
类型与其他字面量类型其实是一样的,比如1
,true
,"hello"
,创建的是表示特定符号的类型
6. 类型拓宽
类型拓宽(type widening)是理解TS类型推导机制的关键。
一般来说,TS在推导类型的时候会放宽要求,故意推导出一个更宽泛的类型,而不限定为每个具体的类型。
声明变量时如果运行以后修改变量的值(例如使用let
和var
声明),变量类型将拓宽,从字面值放大到包含该字面量的基础类型
1 | let a = 'x'; // string |
然而,使用const
声明不可变的变量时,情况不同,会自动的把类型缩窄:
1 | const a = 'x' // 'x' |
我们当然可以显示的标注类型防止类型拓宽
1 | let a:'x' = 'x'; // 'x' |
不过使用**const
声明的对象,并不会缩窄推导的类型**
1 | const obj = { |
因为Javascript对象是可变的,所以在Typescript看来,创建对象之后你可能会更新对象
7. null与undefined
在JavaScript中,null
与undefined
都表示缺少什么,Typescript也支持这两个值,并且都有各自的类型,类型名称就是null与undefined。
这两个类型比较特殊,在TS中,undefined
类型只有undefined
一个值,null
类型也只有null
一个值。
我们在写JavaScript的时候,这两个在语义上有细微的差别,undefined
一般表示尚未定义,而null
表示缺少值。
null
与undefined
在没有开启strictNullChecks
检查的情况下(tsconfig.json中设置了strict:true
默认开始,如果想关闭,可以设置strictNullChecks:false
),会被视为其他类型的子类型,比如string类型会被认为包含了null
与undefined
null
与undefined
也是单独的类型是带有Javascript思维,在遇到复杂结构的时候经常会思考遗漏的问题。最重要的就是忽略类型兼容性的问题。
1 | const temp1:undefined = undefined; |
8. void
在JavaScript中,void
有特殊的用法,比如
1 | <a href="javascript:void(0)">点击</a> |
我们在界面经常这样写来表示阻止a标签的默认行为.
这里的 void(0)
等价于 void 0
,即 void expression
的语法,我们可以使用它来执行一个立即执行函数(IIFE)
1 | void function(){ |
在Typescript中,void
也表示一种类型,用于描述一个内部没有 return
语句,或者没有显式 return
一个值的函数的返回值,如:
1 | function fn1() {} |
fn1
与 fn2
的返回值类型都会被隐式推导为 void
,只有显式返回了 undefined
值的 fn3
其返回值类型才被推导为了 undefined
注:
fn3
只有在tsconfig.json
中开启了strictNullChecks:true
的情况下,其返回值类型才会被推导为undefined
,如果没有开启strict
模式,或者关闭了strictNullChecks
,fn3函数的返回值类型会被默认推导为any
虽然 fn3 的返回值类型会被推导为 undefined,但仍然可以使用 void 类型进行标注
1 | function fn3():void { |
undefined
能够被赋值给 void
类型的变量,就像在 JavaScript 中一个没有返回值的函数会默认返回一个 undefined
,其实主要还是为了兼容性。但是,在strict
模式下,null 类型会报错,除非关闭strictNullChecks
1 | function fn3():void { |
9. 对象字面量
按照我们之前基础类型的惯性思维,在Typescript使用类型描述对象应该是下面这个样子:
1 | let a: object = { |
但是访问b的时候就会发生错误
1 | console.log(a.b); //error 类型object上不存在属性"b" |
为什么把一个变量声明成object类型,却做不了任何操作呢?
其实object类型对值并不了解,就只能表示该值是一个JavaScript对象,仅此而已。因此,当我们输入
1 | a. |
Typescript不会有任何提示。
如果我们不显示注解,直接让Typescript推导
1 | let a = { |
这其实就是对象字面量的语法,当然除了让Typescript推导出对象的解构,我们可以自己进行明确的描述
1 | const a: {b: string} = { |
与前面讲的基本类型不同,使用const声明对象不会导致Typescript把推导的类型缩窄。这是因为JavaScript对象是可变的,所以在JavaScript看来,创建对象之后你可能会更新对象的字段
9.1 可选符号?
默认情况下,Typescript对对象的属性要求十分的严格,如果声明对象有个类型为string的属性name和类型为number的属性age,Typescript将预期对象有这么两个属性。而且有且仅有这两个属性,如果缺少name和age属性,或者多了其他属性,Typescript将报错
1 | // 类型 "{ name: string; }" 中缺少属性 "age",但类型 "{ name: string; age: number; }" 中需要该属性 |
我们可以通过可选符号修饰符?
告诉Typescript某个属性是可选的
1 | let user: { |
注意:如果标注为可选属性,那么这个属性的类型其实是:
类型 | undefined
,也就是说,age?:number
,其实真正的应该是age?:number | undefined
9.2 readonly
除了修饰符可选符号(?
)之外,还可以使用readonly
修饰符把字段标记为只读
1 | let user: { |
readonly不仅仅可以修饰对象的属性,数组,元祖和类中都可以使用readonly
10. 类型别名与接口
我们使用let,const,var为某个值声明变量名,也就是这个值的别名,那么类似的,在Typescript中,可以为类型声明别名
1 | type Age = number; |
Age就是一个number,因此可以让Person的解构定义更容易理解。约定俗成的,一般类型别名的首字母大写
不过Typescript无法推导类型别名,因此必须显式注解。
和使用let声明变量一样,同一种类型不能声明两次
1 | type Color = "red"; |
而且和let,const一样,类型别名采用块级作用域,每一块代码和每一个函数都有自己的作用域,作用域内部的类型别名将遮盖外部的类型别名
1 | type Color = "red"; |
当然,类型别名现在对我们最有用的地方就是减少重复输入复杂的类型。
我们上面声明对象类型要么类型推导,要么使用对象字面量,但是使用类型字面量书写又难看,而且也不方便,如果有多个同样类型的对象,这太麻烦了,类型别名就很简单的解决了这个问题
1 | type User = { |
当然类型别名还能嵌套
1 | type Address = { |
类型别名并不能由TS自动的推导出来,必须手动声明,或者也能使用类型断言
1 | function getUser(): User{ |
对于定义比较复杂结构,接口和类型别名基本的作用一致,上面的类型别名的代码完全可以使用接口进行替换。而且就算是交叉使用也不存在问题
1 | type Address = { |
这里只讲解接口的基本用法,接口其实是面向对象中的概念,而且接口和类型别名之间虽然80%的情况可以互换使用,但是还是有很重要的区别,我们后面一起分析。
11. 结构化类型
Typescript的对象类型表示对象的结构。这是一种设计选择,JavaScript采用的是结构化类型,Typescript直接沿用,没有采取名义化类型
在结构化类型中,类型的兼容性是根据其结构或成员来确定的,而不是依赖于类型的名称或标识符。换句话说,如果两个对象具有相同的结构,即它们具有相同的属性和方法,那么它们可以被认为是相同类型或兼容的类型,即使它们的名称不同。在某些语言中也叫做鸭子类型(鸭子辨型)(意思是不以貌取人)
相比之下,名义化类型的兼容性是根据类型的名称或标识符来确定的。在名义化类型系统中,即使两个对象具有相同的结构,如果它们的名称或标识符不同,它们被认为是不同的类型。
结构化类型通常用于动态类型语言,如JavaScript,而名义化类型通常用于静态类型语言,如Java或C++。
1 | type Person = { |
Person类型能够赋值给Animal类型,如果是Java等后端程序员会觉得这样做不可思议,但是其实将类型去掉,看看编译之后的结果,就能理解了,无非就是简单的对象传值,名字并不是最重要的。
1 | ; |
同样的,就算是class类,一样是结构化类型
1 | class User { |
12. 装箱与拆箱类型
在写javascript的时候,如果暂时还不知道要给对象赋值什么属性,我们经常写成下面这个样子
1 | let obj = {}; |
在typescript中,{}
也可以用来表示类型,一般叫做空对象字面量表示
1 | let obj:{} |
可能我们也会这么想,仅仅就只是声明一个对象,后面再给这个对象赋值具体的属性。
但是,{}
看似不起眼,实际上比之前的object作用范围还要大,object至少规定了需要的是一个对象,而{}
连基础类型都能复制,{}
其实和Object
作用基本一样
1 | let obj1: {} = {name: 'John'}; |
JavaScript 原型链折磨过的同学应该记得,原型链的顶端是 Object 以及 Function,这也就意味着所有的原始类型与对象类型最终都指向 Object,在 TypeScript 中就表现为 Object 包含了所有的类型
1 | const temp1: Object = { name: 'jack' }; |
和 Object 类似的还有 Boolean、Number、String、Symbol,这几个装箱类型(Boxed Types) 同样包含了一些超出预期的类型。以 String 为例,它同样包括 undefined、null、void,以及代表的 拆箱类型(Unboxed Types) string
1 | let str1: string = "Hello World"; |
在任何情况下,你都不应该使用这些装箱类型
下图表示几种对象表示不同的值是否有效:
值 | object | {} | Object |
---|---|---|---|
{} |
是 | 是 | 是 |
[] |
是 | 是 | 是 |
function(){} |
是 | 是 | 是 |
new String('hello') |
是 | 是 | 是 |
'a' |
否 | 是 | 是 |
123 |
否 | 是 | 是 |
Symbol('a') |
否 | 是 | 是 |
null |
否 | 否 | 否 |
undefined |
否 | 否 | 否 |
13. 联合(并集)类型|
有时候一个类型,可能会是string,也有可能是number,或者这个类型,并不仅仅就是一个类型字面量的值,我们希望可以限定是多个值,那这个时候我们应该怎么表示呢?
1 | type Width = number | string; |
同样的,如果是对象类型,一样可以
1 | type Student = { name: string, score: number }; |
由于是联合,从上面的代码中就可以看出,Person类型可以是Student类型的值,也可以是Teacher类型的值,甚至两者兼具结构合并之后的值也行。当然,你也不能两个都不是,所以person4报错
但是使用对象的联合类型很容易让我们产生疑惑。上面的person1和person2对象都好说,取的是联合,所以我们可以要么是Student,要么可以是Teacher。要么其实我们可以两个都是,所以person3这样赋值是没有问题的。
但是要取值的时候就会发生问题
1 | const person3: Person = { name: "jack", age: 18, subject: "math", score: 100 }; |
虽然Student类型和Teacher类型的联合都能赋值给person3,但是实际在使用的时候Student有的属性,Teacher并不一定有,反过来也一样,因此只能调用两者共同的属性name
。
如果联合不相交,那么值只能属于联合类型中的某个成员,不能同时属于每个成员。
联合类型我们非常常用,无论是在声明类型别名,对象字面量或者函数中都能用到
1 | type Color = "黑色" | "白色" | "褐色" | "花色" |
14. 交叉(交集)类型&
交叉类型和符号的意思相似,就表示and的意思,把&
相交的组合起来,值需要全部满足相交组合的类型
1 | type Student = { name: string, score: number }; |
虽然有时候口头上经常会说交集类型,但是在教学的时候,我并不是太喜欢把&
符号称为交集,叫做交叉应该更容易理解一些,不容易给大家造成思想误区。
就拿上面的类型来说,A&B
----> 一说交集应该是,type C = {name:string}
才对啊,最后得到的好像是我记忆中数学的联合类型啊?不用对你的记忆怀疑,你的记忆是对的,你可以把锅丢给翻译
为了便于理解,你可以这样想:C既符合A也符合B,所以是A和B的“交叉”,有了这样的理解,下面出现的一些情况,我们才能更好的理解
相比联合类型,交叉类型的范围就没有那么广泛了,因为你不可能把具体的值使用&
组合,这样意义也就混乱了
1 | type Width = number & string; // never类型 |
number
和string
没有什么交集,因此根本无法给变量赋值,交叉类型始终交叉的是类型,类型字面量或者基础类型,在做类型交叉的时候没有任何意义,因此得到的结果是never。具体类型never类型的使用我们后面讲解
其实,对象字面量类型一样会有这样的效果
1 | type P = { |
如果有同名属性,并且类型一样,就会直接合并,但是如果类型不一样呢?
1 | type P = { |
不过我们可以使用交集类型的特性,达到一些我们需要的效果。
比如,我们可能有一个联合类型,在实际开发中,可能这个联合类型我们并不知道有哪些,或者可能这个联合类型直接赋值给另外一个类型的时候会报错,我们可以使用&
运算符对其进行约束
1 | type params = string | number | boolean; |
当然我们现在代码很简单,只能简单模拟这个情况,讲到一些类型工具之后我们再来看一些复杂情况
比如,我们还能使用交叉类型来实现类似于继承的效果
1 | type Goods = { |
15. 类型控制流和类型查询
15.1 细化:类型的控制流分析
Typescript有非常强大的类型推导能力,不单单有之前我们提到的类型拓宽,还可以类型收缩,比如在类型拓宽中,我们就提到了const声明的变量会自动的转变为类型字面量。当然这仅仅是冰山一角,Typescript甚至可以随着你的代码逻辑,不断地尝试窄收窄,这一能力称之为类型的控制流分析(也可以简单的理解为就是类型推导)
有些人也把类型的控制流分析简称为类型收缩(收窄),但是这种称呼容易和const声明类型的类型收窄引起混淆。
不过怎么称呼无所谓,在具体的语境中,能理解就行。
1 | function parse(value: number | string | boolean | null | undefined) { |
你可以把整个流程控制想象成一条河流,从上而下流过你的程序,随着代码的分支分出一条条支流,在最后重新合并为一条完整的河流。
在类型控制流分析下,每流过一个if分支,后续联合类型的分支就会少一个,因为这个类型已经在这个分支处理过了,不会进入下一个分支
15.2 typeof
:类型查询
上面的代码中,我们使用了在JavaScript很常用的一个操作符typeof
,在JavaScript中,我们常常用typeof
来检查变量类型,通常会返回"string"
/"number"
/"boolean"
/"function"
/"object"
等值。
在Typescript中给typeof
操作符还赋予了新的功能:类型查询(Type Query Operator)
简单来说,可以通过typeof
获取自动推导的类型,给typeof
一个值,就可以帮你推导出这个值的类型
1 | let temp1 = "hello1"; |
对象也是可以的
1 | const user = { |
16. instanceof与in
16.1 instanceof
实例判断
typeof
类型检查只能判断"string"
/"number"
/"boolean"
/"function"
/"object"
等值。如果遇到了具体的对象类型判断就无能为力了,因此,可以使用instanceof
关键字
1 | class Animal { |
16.2 in
:属性检查
JavaScript 语言中,in
运算符用来确定对象是否包含某个属性名
1 | const obj = { a: 123 }; |
在Typescript中,in
检查对象是否具有特定的属性,并使用该属性区分不同的类型。它通常返回一个布尔值,表示该属性是否存在于该对象中。
1 | type Circle = { |
17. 字面量类型检查(可辨识联合类型)
再结合着对象的联合类型来看一下问题:
1 | type UserTextEvent = { value: string, target: HTMLInputElement}; |
event.value
的类型可以顺利的细化,但是event.target
却不可以,因为handle函数的参数是UserEvent
。联合之后的UserEvent
,其实类似于:
1 | type UserEvent = { |
也就是当value:string
的时候,target
可以选择HTMLInputElement | HTMLButtonElement
也就是当value:number
的时候,target
也可以选择HTMLInputElement | HTMLButtonElement
因此,Typescript需要一种更可靠的方式,明确对象的并集类型的具体情况。
最常见的方式是,使用字面量类型进行标记,这样具体有值的情况下,就相当于在进行值的判断,这样Typescript就能很精确的推导出,具体的对象并集类型到底是哪个类型了
1 | type UserTextEvent = { type:"TextEvent", value: string, target: HTMLInputElement}; |
一般像这种多个类型的联合类型,并且多个类型含有一个公共可辨识的公共属性的联合类型,还有一个专门的称呼可辨识联合类型
可辨识联合类型对初学者有实际的指导作用,我们在创建类型的时候,就需要想着最好创建带有可辨识的联合类型,而不是可选字段
比如,有这样的情况,如果是circle
的时候,有radius
属性,如果是rect
情况,有width
和height
属性。对于初学者,很有可能创建成下面的类型:
1 | type Shape = { |
上面这种方式kind字段没有与其他字段建立关系,因此,不能保证可选属性是否有值。所以报出了未定义的错误(当然在后面的学习中我们可以使用非空断言!
处理)。
可辨识的联合类型是一种更好的处理方式:
1 | type Circle = { kind: "circle", radius: number } |
18. 自定义守卫(谓语动词 is)
自定义守卫是指通过 {形参} is {类型}
的语法结构,来给返回布尔值的条件函数赋予类型守卫的能力
1 | function isString (input: any) { |
类型收窄只能在同一的函数中,如果在不同的函数中就不起作用。
只要我们加上谓语动词:
1 | function isString (input: any): input is string { |
自定义类型守卫在我做一些比较复杂类型判断的时候比较有用
1 | type Box = { |
上面的这个代码,其实就是简单模拟了一下Vue3中isRef和unRef的ts代码
1 | export function isRef(r: any): r is Ref { |
其实前面说过的字面量的类型检查
,typeof
,instanceof
,in
以及自定义守卫
在Typescript中有统一的称呼,都叫做类型守卫,其目的其实都是在控制流分析的时候,帮助typescript收紧类型,便于推断
19. never
never
类型根据其英文翻译,就表示从来没有
,绝不
。其实之前已经见到过这个类型
1 | type A = string & number; // never |
我们之前不是讲过有null
,undefined
和void
类型吗?这三个都是有具体意义的,也表示具体的类型,undefined
表示尚未定义,null
表示缺少值,甚至是void
就表示一个空类型,就像没有返回值的函数使用 void 来作为返回值类型标注一样。
而 never 才是一个“什么都没有”的类型,它甚至不包括空的类型,严格来说,never 类型不携带任何的类型信息。
比如下面的联合声明:
1 | type Foo = string | number | boolean | undefined | null | void | never; |
我们把常见的基础类型都放入到了联合声明中,但是将鼠标悬浮在类型别名之上,你会发现这里显示的类型是:string | number | boolean | void | null | undefined
,never
直接被无视掉了。
注意:这个特性在以后的类型编程条件判断中经常会被用到,使用never来填充数据
在typescript的类型系统中,never
类型被称为 Bottom Type,是整个类型系统层级中最底层的类型
如果说any
,unknown
是其他每个类型的父类型,那么never
就是其他每个类型的子类型。
这意味着,never类型可以赋值给其他任何类型,但是反过来,却行不通
通常我们不会显式地声明一个 never
类型,这是没有任何意义的,它主要被类型检查所使用。
不过在实际工作中,特别是在团队开发中,我们可以利用never的特性与类型的控制流分析,让typescript做出更合理的处理
1 | type Method = "GET" | "POST"; |
上面的代码没有什么问题,但是如果某一天,Method
类型加入了新的联合类型,比如type Method = "GET" | "POST" | "PUT" | "DELETE";
,特别是在团队开发中,这个时候,request函数是没有任何感知的。
1 | type Method = "GET" | "POST" | "PUT" | "DELETE"; |
将代码修改为现在的这个样子,虽然现在有报错了,method
根据类型流分析,还剩下"PUT" | "DELETE"
类型,所以不能赋值给never
类型。但是将错误扼杀在摇篮中,才是在团队项目中想要的结果,而不是等运行了,才去一个个排查,特别是这种隐藏的bug,在团队的成千上万行代码与模块中,去找到这个问题,是非常痛苦的问题。
这种方式也叫做穷举式检查,积极的对不期望的情况进行错误处理,在编译时就捕获未处理的情况。而不是默默地忽略它们
比如,前面的代码,我们也可以进行修改:
1 | type Circle = { kind: "circle", radius: number } |
如果新加一个类型const _neverCheck: never = shape;
这行代码就会报错,因为控制流分析并没有完全结束
1 | type Circle = { kind: "circle", radius: number } |
还有在某些情况下使用 never 确实是符合逻辑的,比如一个只负责抛出错误的函数:
1 | function fn():never { |
在类型流的分析中,一旦一个返回值类型为 never
的函数被调用,那么下方的代码都会被视为无效的代码:
1 | function fn():never { |
never
类型在我们后面讲解的条件类型中也可以做出很有意思的处理
20. 数组与元组
20.1 数组
数组类型有两种声明方式:
1 | 类型[] |
1 | let a = [1, 2, 3]; |
一般情况下,数组应该保持同质。
也就是说,不要在同一个数组中存储不同类型的值,存数值的,就是存数值的数组,存字符串的,就是存字符串的数组。设计程序时要规划好,保持数组中的每个元素都具有相同的类型。
虽然这样让数组变得不灵活了,不过这就是类型语言和javascript这种灵活语言的区别。如果不这么做,我们需要做一些额外的工作,让typescript相信我们执行的操作是安全的。
比如上面的e
或者f
,如果我们想映射这个数组,把字母变成大写,把数字变成乘以2:
1 | let g = [1, "a"]; |
为此,必须使用typeof检查每个元素的类型,判断元素是数字还是字符串,然后再做相应的操作
对象字面量当然也能和数组一起使用
1 | const users: { |
当然写成类型别名或者接口肯定可读性更高一些
1 | type User = { |
一般情况下,初始化一个空数组,数组的类型为any
注意:如果启用了
strictNullChecks
配置,同时禁用了noImplicitAny
,声明一个空数组,那么这个未标明类型的数组会被推导为never[]
类型
1 | const arr = []; // any[] |
**注意:**当这样的数组离开定义时所在的作用域后,TypeScript将最终确定一个类型,不再扩展。
在实际工作中,可以很好的利用这一特性
1 | function fn() { |
readonly
修饰符也可以用来修饰数组,用于创建不可变的数组,只读数组和常规数组没有多大差别,只是不能就地更改。如果想创建只读数组,需要显示的注解类型。
1 | const arr: readonly number[] = [1, 2, 3]; |
在只读数组中,只能使用非变型方法,例如concat
和slice
,不能使用可变形方法,比如push
和splice
**注意:**只读数组不可变的特性能让代码更易于理解,不过其背后提供支持的任然是常规的Javascript数组。这就意味着,即便只是对数组做很小的改动,也要复制整个原数组。
对于小型数组来说,没什么影响,但是对于大型数组,可能会造成极大的影响。
如果打算大量使用不可变的数组,建议使用immutable包
使用并集数组的细节
使用并集数组类型,我们一般有两种的声明方式,两种方式大体上一样,但是有一些细节上的区别
1 | // 可以是number数组,可以是string,也可以是number和string类型混合的数组 |
20.2 元组
元祖类型是数组的子类型,是定义数组的一种特殊方式。
长度固定,各索引位置上的值具有固定的已知类型。在某些固定的场合,使用元祖类型更加方便,严谨性也更好
声明元组必须显式注解类型,因为声明元组与数组的声明相同,都是使用方括号[]
,因此默认推导出来的都是数组类型
比如,在Javascript中,我们经常使用数组来表示一个坐标点。这种做法在TS中也没有任何问题,但是如果我们使用元祖类型,那么无论是提示还是代码严谨性,就更加的好
1 | const pointer1: number[] = [10, 20]; |
在typescript4.0中,甚至加入了具名元祖
,让元祖类型的可读性更高
1 | const pointer3: [x:number, y:number] = [20, 30]; |
很明显,元祖结构进一步提升了数组结构的严谨性
不过元祖类型还是有一个问题,虽然名义上限定了有几个值,并且如果像下面这样写,会报错
1 | pointer3[2] = 40; // error 不能将类型40分配给类型undefined |
但是却可以使用push
方法往里面加入新的值
1 | pointer3.push(40); |
因此,我们可以将元祖类型限制为可读readonly
元祖
1 | const pointer3: readonly [x:number, y:number] = [20, 30]; |
21. 方括号运算符[]
数组当然需要使用[]
,在Javascript中我们经常使用[]
来获取数组的值,或者动态引用获取对象属性的值
1 | const arr = ["a", "b", "c", "d", "e"]; |
在Typescript中,方括号运算符[]
用于类型计算,取出对象类型的键对应的值的类型,比如类型[键名]
,简写为T[K]
会返回T
类型的属性K
的类型。
1 | type Person = { |
方括号的参数如果是联合类型,那么返回的也是联合类型。
1 | type AgeOrName = Person['age'|'name']; // string | number |
甚至可以获取数组的具体类型,注意下面的写法:
1 | const arr = ["a", "b", "c", "d", "e"]; |
因为在Javascript中,数组其实就是key:value
的键值对,而数组的键也就是下标都是number类型
同样,如果是一个对象字面量类型的数组,一样会得到数组中对象字面量类型:
1 | type User = { |
如果是一个元组,就可以得到元组类型中所有位置上类型的联合类型:
1 | const roles: ["Admin", "User", "Guest"] = ["Admin", "User", "Guest"]; |
22. 类型断言
类型断言(Type Assertion)可以用来手动指定一个值的类型。
在使用 TypeScript
的过程中,你可能会遇到这种情况:你比 TypeScript
更加清楚某个值的类型。 比如你从异步请求中拿到一个类型为any
的值,但你清楚的知道这个值就是string
类型,这个时候你可以通过类型断言方式告诉编译器:这就是一个string类型。类型断言有点类似于其他语言的类型转换,注意只是类似,它没有运行时的影响,类型断言只是在编译阶段起作用。
22.1 语法
1 | 值 as 类型 |
1 | let someValue: any = "this is a string"; |
注意在 tsx
语法中使用 值 as 类型
。
22.2 用途
22.2.1 联合类型断言:
1 | type MyType = string | number | boolean; |
其实从上面的代码中可以很明显的看出来,类型断言是有很明显的类型安全隐患的。所以我们一般在使用的时候,需要自己明确的知道确实可以进行断言,再进行操作。
22.2.2 父类型断言为子类型
1 | class Animal { |
还记得我们之前的instanceof
吗?
1 | class Animal { |
其实类型安全的做法就是应该使用类型守卫,但是有时候可能使用起来不那么方便,或者说其实类型我们很确定,那就可以直接使用类型推断,比如常见的DOM事件操作
1 | const inputDom = document.querySelector("input"); |
22.2.3 将任何一个类型断言为 any
(某些情况下可以被断言为unknown
)
有时候,当我们引用一个在此类型上不存在的属性或方法时,就会报错:
1 | const obj = { |
对象obj
上没有sex
这样的一个属性,当然TS就会提示错误。
但有的时候,我们非常确定这段代码不会出错,比如:
1 | window.foo = 1; // 类型“Window & typeof globalThis”上不存在属性“foo” |
往全局对象window
上添加新的属性,这可能是我们经常会做的操作,但是window
对象类型上没有我们foo
这个属性,当然同样也会报错。
此时我们可以使用 as any
临时将 window
断言为 any
类型:
1 | (window as any).foo = 1; |
当然,上面的这个例子我们也可以通过扩展
Window
的类型来解决这个问题:
1
2
3
4
5
6
7
8 export {}
declare global {
interface Window {
foo: number;
}
}
window.foo = 1;不过如果只是临时的增加
foo
属性,as any
会更加方便。我的意思是,我们不能滥用
as any
,但是也不要完全否定它的作用,我们需要在类型的严格性和开发的便利性之间掌握平衡。才能发挥出 TypeScript 最大的价值。
22.2.4 将 any/unknown
断言为一个具体的类型
在日常的开发中,我们不可避免的需要处理 any
或者unknown
类型的变量,它们可能是由于第三方库未能定义好自己的类型,也有可能是历史遗留问题,还可能是受到 TypeScript 类型系统的限制而无法精确定义类型的场景。
遇到 any
或者unknown
类型的变量时,我们可以通过类型断言把 any
或者unknown
断言为精确的类型。
1 | // 第三方API或者历史遗留函数 |
22.3 限制
并不是任何一个类型都可以被断言为任何另一个类型。
1 | let str = "123"; |
两个完全没有关联的类型进行断言,这当然会报错,相信大家也能想的通,因此,什么情况下能断言,就很好理解了。
具体来说,若 A
兼容 B
,那么 A
能够被断言为 B
,B
也能被断言为 A
。
1 | let str1:"hello" = "hello"; |
对象类型也一样
1 | let a: Animal = new Animal(); |
22.4 非空断言
当你确信某个值不是null
或undefined
时,可以使用非空断言
语法: 值!
,比如someValue!
1 | let maybeString: string | null = "hello"; |
1 | function getRandom(length?: number) { |
1 | type Box = { |
22.5 双重断言
既然:
- 任何类型都可以被断言为
any
(某些情况下可以被断言为unknown
) any
或unknown
可以被断言为任何类型
那么就可以使用双重断言 as any as 类型
来将任何一个类型断言为任何另一个类型
1 | let str = "123Hello"; |
这样写很明显有类型安全的问题,类型断言并不等于类型转换,编译之后是没有类型的,所以通过tsc编译之后你会发现,其实就是把变量str
赋值给了变量n
1 | let str = "123Hello"; |
22.6 as const断言
as const
断言 用于指示 TypeScript 将一个变量视为常量,并据此推断出最具体的类型。并且,使用 as const
时,TypeScript 会将数组视为只读元组,对象的属性也会被视为只读属性,且对象或数组中的值会被推断为字面量类型,而不是更一般的类型(如 string
、number
等)
1 | // a 的类型是 'Hello' |
as const结合着方括号运算符,有时候可以非常方便的处理一些看起来比较复杂的问题。
比如,需要将数组中的内容转换为联合类型
1 | const roles = ["角色列表", "用户删除", "用户查询", "权限详情"] as const |
23. satisfies
satisfies
是一个类型操作符,它是TS4.9
的新功能。和类型断言as
功能比较类似,但是比类型断言更加安全也更加智能,因为他能在满足类型安全的前提下,自动帮我们做类型收窄和类型提示。
1 | interface IConfig { |
再比如在某些映射类型中:
1 | type MyElement = { |
可以使用satisfies
1 | const element = { |
25. 枚举
25.1 为什么使用枚举?
在讲解具体使用枚举之前,首先要理解为什么要使用枚举
其实枚举在其他语言中它都是老朋友了,比如java
,c#
。
比如我们现在要定义春夏秋冬,颜色,月份,星期,方向等等有序列或者比较固定离散值(可以被清晰区分并计数的值)的情况,在javascript中,我们会想到用const定义一系列常量,在Typescript我们会想到用字面量的联合类型来处理
1 | type Gender = "男" | "女"; |
但是这么写,其实也会遇到java
,c#
语言在处理上的一些问题,也就是逻辑含义和真实的值产生了混淆,会导致当修改真实值的时候,产生大量的修改
简单来说,就是上面的"red" | "blue" | "green"
颜色如果想要修改为其他的颜色,比如中文的"红"|"蓝"|"绿"
,不单单声明要改,整个判断也需要修改。所以无论是像java
,c#
这样的类型语言,或者是像Typescript才有了枚举这样的类型。
Typescript声明枚举非常简单
1 | enum Color { |
按约定,枚举名称最好为首字母大写的单数形式。枚举中的键也为首字母大写
Typescript枚举大体分为两种:字符串到字符串之间的映射,字符串到数字之间的映射。
Typescript可以自动为枚举中的各个成员推导对应的数字。默认从0开始,依次往下,你也可以自己手动设置
1 | enum Color { |
甚至如果手动设置一个开头,Typescript会自动的往下为你推导下一个枚举对应的数值
1 | enum Color { |
当然也可以定义字符串到字符串的映射
1 | enum Color { |
当然,前面为什么说大体分为两种,因为其实还可以数值和字符串混合,这种一般称为异构枚举,不过这种就不推荐了
1 | enum Color { |
再来一个例子,大家理解一下上面这句话的意思
写一个函数处理参数传递的各种不同的状态,比如"success","notfound","error"
1 | type StatusType = "success" | "notfound" | "error"; |
上面的代码虽然通过类型字面量的联合类型进行了判断,但是某一天要修改类型了,改成中文 "成功"|"未找到"|"失败"
,或者直接改成数字,200 | 404 | 500
,那么下面所有的判断都需要改。但是如果一开始就使用的是枚举,事情就简单了。就算要修改,把枚举对应的值,修改了就行了。
1 | enum Status { |
25.2 双向映射
枚举和对象的差异还在于,对象是单向映射的,我们只能从键映射到键值。而枚举是双向映射的,即你可以从枚举成员映射到枚举值,也可以从枚举值映射到枚举成员:
1 | enum Direction { |
为什么可以这样,我们看一下编译后的产物就知道了
1 | var Direction; |
obj[k] = v
的返回值即是 v,因此这里的 obj[obj[k] = v] = k
本质上就是进行了 obj[k] = v
与 obj[v] = k
这样两次赋值。
但需要注意的是,仅有值为数字的枚举成员才能够进行这样的双向枚举,字符串枚举成员仍然只会进行单次映射:
1 | enum Direction { |
编译之后
1 | var Direction; |
通过上面的代码,大家有没有发现,枚举类型相当的特殊,既作为类型,也可以是值。
25.3 枚举的一些问题
在Javascript中,是没有enum枚举类型的,虽然有相关的enum提案,不过一直没有进展。所以对于枚举来说,实际上是有一些小坑在里面的。
比如,从上面的编译结果可以看出,枚举类型在实际运行环境中编译成了一个立即执行函数(IIFE)。如果是普通业务,这不是什么问题。但如果这是一个 ts 写 npm 第三方库,需要提供给别人调用,就会发现因为枚举类型变成了立即执行函数(IIFE
),无法被 tree shaking
优化掉,因为这个 IIFE
有副作用。
当然了,一般枚举的内容也不会太多,其实影响有限,但是这确确实实是枚举存在的一个问题,特别是现在特别鼓吹ESM浏览器模块化的今天,这个问题可能会被放大。
还有一个问题是,由于枚举是双向映射的,那么,下面的代码注意观察
1 | enum Direction { |
Direction[99]
这样的写法,在Typescript中竟然没有报错…或者这样写
1 | const n: number = 11; |
这样写,竟然也不会报错,当然这样写是能够理解的,因为我们有时候会使用枚举实现一些更加灵活的场景处理,比如下面的代码
1 | enum AttackType { |
25.4 常量枚举
如果希望屏蔽不安全的访问操作,可以使用常量枚举
1 | const enum Direction { |
- 常量枚举不允许反向查找
- 常量枚举默认并不会生产任何Javascript代码,而是在用到枚举成员的时候直接插入对应的值
1 | console.log(0 /* Direction.Up */); // 0 |
上面的常量枚举代码,编译之后就只有这么一句
25.5 isolatedModules
如果在工程中使用枚举类型,务必要设置tsconfig的属性isolatedModules:true
,因为有些打包工具并没有依赖Typescript的tsc
进行类型检查和类型转译,像 esbuild
和 Babel
这样的工具会单独编译每个文件,因此它们无法判断导入的名称是类型还是值。所以有一些Typescript的特性是容易产生错误的,比如const enum
。这个内容在vite和esbuild中都有相关的说明