TypeScript
TypeScript
TypeScript(简称 Ts)是微软公司开发的一种基于 JavaScript (简称 JS)语言的编程语言。
Ts 是对 Js 的增强,是 Js 的超集,继承了 Js 全部语法,并额外增加了一些新的语法。
Ts 补充了类型系统,是对弱类型(动态类型)语言 Js 添加的强类型(静态类型)限制,是 Ts 最主要的部分(Type)。
Ts 的优点
- 有利于代码的静态分析,确定变量类型。
- 有利于提前发现代码编写错误,提高项目的可维护性。
- 使 IDE 可以正确提示、补全。
- 有助于项目重构(比如接口响应体结构类型的编写)。
Ts 的缺点
- 降低了弱类型代码的灵活性。
- 增加了工作量,Ts 不一定适合小型和个人项目。
- 兼容问题,老的库未必有 Ts 支持。
如何学习 Ts
可以通过官方文档结合阮一峰大佬的TypeScript 教程进行学习,本文的知识点也是我通过大佬的TypeScript 教程学习 Ts 的过程中做的一个粗糙的提取总结 🫡。
可使用TypeScript Playground进行在线练习。
官网:https://www.typescriptlang.org/zh/docs/
TypeScript 教程:https://wangdoc.com/typescript/
TypeScript Playground:http://www.typescriptlang.org/play/
如果使用阮一峰大佬的教程进行学习,可以用一下我写的油猴脚本,主要就个人使用习惯优化了目录的布局,同时提供了黑夜模式。
Ts 的编译
Ts 并不能直接在浏览器中运行,所以 Ts 项目需编译为 Js。
Ts 编译时会将类型相关代码删除(Ts 的大部分语法都是可擦除语法),只留下 Js 代码。
因此,Ts 的类型检查只是编译时的类型检查,而不是运行时类型检查。
官方提供了一个编译器tsc
(Ts compile),是一个npm
模块,可以将 Ts 编译为 Js 供浏览器运行,使用以下命令安装、编译:
npm install -g typescript # 全局安装
tsc -v # 查看版本
tsc x.ts # 会在当前目录编译x.ts为x.js
非官方npm
模块,可以直接运行 Ts,使用以下命令安装:
npm install -g ts-node # 全局安装
ts-node x.ts # 运行x.ts
Ts 的编译策略
Ts 默认是宽松的编译策略,类型写错并不会导致编译过程停止,只会给出编译错误,编译过程继续执行。
设计目的:微软官方认为开发者更了解自己的代码,Ts 只负责报错警告开发者,但编译继续,让开发者自己决定如何处理。
如果希望一旦报错就停止编译,不生成编译产物,可以通过tsconfig.json noEmitOnError
配置调整。
Ts 的类型推断
在 Ts 中,类型声明并不是必需的,如果没有,Ts 会自己推断类型,但不能保证推断出的正确的类型。
Ts 的这种设计目的,主要便于渐进式地使用 TS 重置过渡 JS 项目为 TS 项目。
any 类型
没有任何限制的类型,该类型可以是任意类型的值,当然,到处用 Ts 就变成 anyScript 了。
类型一旦设为 any,Ts 实际上就关闭了该值的类型检查了。
从集合论的角度看,any 类型是其他类型的全集,是顶层类型
。
any 的类型推断
Ts 会自动推断类型,无法推断的,Ts 会认为该值隐式为 any 类型。
通过启用配置项noImplicitAny
,推断为 any 类型 Ts 就会报错,除了未赋值的情况,因此,不赋值的变量在 Ts 中一定要显式声明类型,否则会存在安全隐患。
any 的类型污染
any 类型除了关闭类型检查,还有一个变量污染的问题,any 可以赋值给其他任何类型的变量,从而导致其他变量也关闭了类型检查。
let any: any = '123'
let num: number = 123
num = any
console.log(num * 3) // 对字符类型做计算,不报错
unknown 类型
用于解决 any 类型的类型污染问题,是一个更加安全、使用更加严格的 any 类型。
从集合论的角度看,unknown 类型是其他类型(除了 any)的全集,也是顶层类型
。
unknown 类型的限制
与 any 类型的不同之处在于,unknown 类型赋值给除了 any 类型和 unknown 类型之外的类型都会报错。
unknown 类型不能直接使用,有如下限制(挺繁琐的):
- 无法直接调用 unknown 类型的属性和方法:直接调用 unknown 类型变量的属性和方法,或者直接当作函数执行,都会报错。
- 运算限制:只支持比较运算(运算符==、===、!=、!==、||、&&、?)、取反运算(运算符!)、typeof 运算符和 instanceof 运算符,其他运算(+、-、*、/等)都会报错
取消 unknowun 类型的限制
通过类型收窄
(类型缩小),收窄 unknown 变量的类型范围(类型推断
范围),使 Ts 确认类型后就可以取消限制,使用属性和用于运算,以对象和 string 举例:
// 使用限制demo1
let unk: unknown = { foo: 123 }
// unk.foo // 报错
if (typeof unk === 'object' && 'foo' in unk /* foo属性存在于unk */) {
console.log(unk.foo) // 正确
console.log(unk.foo + any) // 正确
}
// 使用限制demo2
let str: unknown = 'hello'
if (typeof str === 'string') {
str.length // 正确
}
设计目的
只有明确 unknown 变量的实际类型,才允许使用它,防止像 any 那样可以随意乱用,污染其他变量。
类型收窄
(类型缩小)以后再使用,就不会报错。
never 类型
空类型,不包含任何值,不可能返回的值。
let x: never // 无法赋值
function f(): never {
throw new Error('Error') // 抛出错误,不存在返回值
}
从集合论角度看,空集是其他任何集合的子集,因此,never 类型可以赋值给其他任意类型。
在 Ts 中,任何类型都包含了 never 类型,never 类型是其他类型共有的,Ts 把这种情况称为底层类型
。
Ts 有两个顶层类型(any 和 unknown),但是底层类型只有 never 唯一一个。
Ts 的类型系统
Ts 继承了 Js 的类型设计,并在这个基础上,定义了一套自己的类型系统。
基本类型
Js 将值的类型分为 8 种:
- boolean,只包含 true 和 false 两个布尔值。
- string,包含所有字符串。
- number,包含所有整数、浮点数、非十进制数(0xffff)。
- bigint,包含所有的大整数(123n、0xffffn),bigint 与 number 类型不兼容。
- symbol,
const x:symbol = Symbol()
。 - object,在 Js 中,object 类型包含所有的对象、数组及函数。
- undefined,表示未定义。
- null,表示空。
这 8 种类型是 Ts 的基本类型,复杂类型由它们组合而成。
undefined 和 null 的注意项
在关闭 Ts 编译设置strictNullChecks
时,undefined 和 null 会被类型推断为 any,导致它们可以赋值给其他类型,打开后即可正确推断类型。
let a = undefined // any
let c = null // any
// 打开
let a = undefined // undefined
let c = null // null
包装对象类型
Js 中,除了 undefined、null 两个特殊类型,object 属于复合类型
,其他五种类型(boolean、string、number、bigint、symbol),属于原始类型
(最基本的、不可再分的)。
这五种原始类型的值,都有对应的包装对象,包装对象即是:
'str'.charAt(1) // 't'
上面示例中,字符串执行了方法,众所周知,Js 中只有对象才有方法,原始类型本身不带方法,之所以可以调用,是因为 Js 会将字符串自动转为包装对象
,charAt 方法定义在包装对象上。
五种包装对象中,symbol 类型和 bigint 类型无法直接获取它们的包装对象(即 Symbol()和 BigInt()不能作为构造函数使用),余下的 Boolean()、String()、Number()类型则可以。
const s = new String('str')
typeof s // 'object'
s.charAt(1) // 't'
上面示例中,s 就是字符串 hello 的包装对象,typeof 运算符返回 object,而不是 string,但是本质上它还是字符串,可以使用所有的字符串方法。
注意,String()只有当作构造函数
使用时(即带有new
命令调用),才会返回包装对象
。如果当作普通函数使用(不带有 new 命令),返回就是一个普通字符串
。其他两个构造函数 Number()和 Boolean()也是如此。
包装对象类型与字面量类型
由于包装对象的存在,故每一个原始类型的值都有包装对象和字面量
的情况。
'str' // 字面量
new String('str') // 包装对象
为了区分这两种情况,TypeScript 对五种原始类型分别提供了大写和小写两种类型
。
Boolean 和 boolean
String 和 string
Number 和 number
BigInt 和 bigint
Symbol 和 symbol
小写类型包含字面量,而大写类型同时包含包装对象和字面量,因此,小写类型可以看作是大写类型的子集。
const s1: String = 'str' // 正确
const s2: String = new String('str') // 正确
const s3: string = 'str' // 正确
const s4: string = new String('str') // 报错
建议只使用小写类型,不使用大写类型。
因为绝大部分使用原始类型的场合,都是使用字面量,不使用包装对象。而且,TypeScript 把很多内置方法的参数,定义为了小写类型,使用大写类型会报错。
const n1: number = 1
const n2: Number = 1
Math.abs(n1) // 1
Math.abs(n2) // Ts内部定义abs返回值为小写number类型,故报错
bigint()、symbol()不能作为构造函数使用,建议一直用小写类型就行。
Object、object 类型
Ts 的对象类型也有大写 Object 和小写 object 两种。
Object 类型
代表 Js 广义上的对象,即所有可以转为对象的值,都是 Object 对象,这囊括了几乎所有的值,Js 中万物皆是对象!
let obj: Object
// let obj: {} // Object 可以简写为 {}
obj = true
obj = 'hi'
obj = 1
obj = { foo: 123 }
obj = [1, 2]
obj = (a: number) => a + 1
obj = undefined // 打开strictNullChecks时,报错
obj = null // 打开strictNullChecks时,报错
上面示例中,除了 undefined、null
不能转为对象,其他任何值都可以赋值给 Object 类型。
空对象{}
是 Object 类型的简写形式
,所以使用 Object 时常常用空对象代替。
无所不包的 Object 太宽泛了,无法符合直觉使用,所以,需要小写类型 object。
object 类型
小写的 object 类型表示Js里狭义的对象
,即可以用字面量表示的对象,只包含对象、数组和函数。
let o: object = { foo: 123 }
// let o: object = new Object({ foo: 123 }) // new一个也可以使用小写的object类型
console.log(o.toString()) // 正确
console.log(o.foo) // 报错
let o: object = new String(213) // 包装对象
o = [1, 2]
o = (a: number) => a + 1
// 不能赋值原始类型值,只包含对象、数组和函数,报错
o = true
上面示例中,toString()是对象的原生方法
,可以正确访问,而自定义属性
foo,访问则会报错。
因为无论大写类型 Object 还是小写类型 object,都只包含Js的原生方法和属性
,自定义属性与方法不存在这两个 Ts 类型之中。
大多数时候我们只希望包含真正的对象,所有,建议总是用小写类型object
,而不是大写类型 Object。
undefined 和 null 的特殊性
undefined 和 null 既是值,又是类型。
任何其他类型的变量都可以赋值为 undefined 或 null。
let age: number = 24
age = null // 正确
age = undefined // 正确
上面示例中,undefined 和 null 赋值给 number 类型变量并不会报错,这是为了对齐Js的设计和行为
,在 Js 中,undefined 和 null 表示未定义和空,所以 Ts 允许任意类型都可以赋值为这两个值。
但也存在该行为造成的弊端:
const obj: object = undefined
obj.toString() // 编译不报错,运行就报错
上面示例中,obj 上并不存在 toString()方法,但 Ts 不会警告和编译报错,而是在运行时表现,这并不符合 Ts 的设计思路(把错误体现到编写阶段)。
为了解决这个问题,Ts 提供了strictNullChecks
配置项,只要打开这个选项,Ts 就会严格检查空值,undefined 和 null 就不能赋值给其他类型的变量(除了 any 类型和 unknown 类型),也不能相互赋值,只能赋值给 any 和 unknown 类型的变量。
值类型
在 Ts 中,单个值也可以是类型,称为值类型,如:
let v: '123' // v: '123'
const x = '123' // x: '123'
上面示例中,const 自动推断未声明类型的 const 为值'123'
类型,因为 const 一旦声明就不能改变,相当于常量
。
const vo = { foo: 123 } // vo: { foo: number }
当为对象时,为对象属性推断的类型,而不是值类型,因为Js中const赋值为对象时,属性是可以改变的
。
const xc: 5 = 4 + 1 // 报错
const xc: 5 = (4 + 1) as 5 // 使用类型断言,正确
// 父子类型互相赋值
let xn: 5 = 5
let yn: number = 4 + 1
xn = yn // 报错
yn = xn // 正确
值类型可能也会出现一些奇怪的报错(类型兼容)问题,上面示例中,Ts 推断 4 + 1 为 number 类型,由于 5 是 number 的子类型,number 是 5 的父类型,子类型变量不能赋值为(赋值为 子 = 父)父类型变量,但父类型变量则可以赋值为子类型变量
(详见后文类型的兼容、类型的断言)。
实际开发中,值类型用处不大。
联合类型
指多个类型联合组成一个新类型
,使用符号|
表示。
联合类型 A|B 表示,任何一个类型只要属于 A 或 B,就属于联合类型 A|B,也可以与值类型相结合,如:
let lh: string | number | false = 123
lh = '123' // 正确
lh = false // 正确
lh = true // 报错
打开编译选项strictNullChecks
后,对于包含可能包含空值的变量,也可以采取联合类型的写法:
let name: string | null
name = 'John'
name = null
事实上,联合类型可以看作是一种类型扩大
(类型放大),实际处理时会遇到需要类型收窄
(类型缩小)的情况:
function printId(id: number | string) {
if (typeof id === 'string') {
console.log(id.toUpperCase())
} else {
console.log(id)
}
}
上面示例中,如果不做类型收窄
(类型缩小)确定入参类型,id.toUpperCase()方法调用就会报错。
交叉类型
指多个类型组成的一个新类型
,使用符号&
表示。
交叉类型 A&B 表示,任何一个类型必须同时属于 A 和 B,才属于交叉类型 A&B,即交叉类型同时满足 A 和 B 的特征,当然,类型交叉之间不能存在冲突。
let x: number & string // 不可能存在的类型
let obj: { foo: string } & { age: 123 } = { foo: '123', age: 123 }
上面示例中,变量 obj 同时具有属性 foo 和属性 age。
交叉类型常常用来为对象类型添加新属性
。
type A = { foo: number }
type B = A & { bar: number }
上面示例中,类型 B 是一个交叉类型,用来在 A 的基础上增加了属性 bar。
type 命令
用于定义一个类型的别名
,使类型更具语义化。
type Name = string
let name: Name = 'zhiyu'
别名声明的作用域是块级作用域,同一作用域中,别名的声明不允许重复:
// type Name = string // 报错
if (name === 'zhiyu') {
type Name = string
}
别名支持表达式,允许嵌套别名:
type World = 'world'
type Greeting = `hello ${World}`
let hello: Greeting = 'hello world'
type 命令属于类型相关的代码,编译成 JavaScript 的时候,会被全部删除。
typeof 运算符
在 Js 中,typeof 是一个一元操作符(x 元代表需要 x 个值才能使用的操作符,如二元: x && x),代表操作数的类型,typeof 运算符只可能返回八种结果,而且都是字符串。
typeof undefined // "undefined"
typeof true // "boolean"
typeof 1337 // "number"
typeof 'foo' // "string"
typeof {} // "object"
typeof parseInt // "function"
typeof Symbol() // "symbol"
typeof 127n // "bigint"
Ts 将 typeof 运算符移植到了类型运算
,它的操作数依然是一个值,但是返回的不是字符串,而是该值的 Ts 类型
:
const a = { x: 0 }
type T0 = typeof a // { x: number }
type T1 = typeof a.x // number
也就是说,typeof 在 Ts 中既可以作为值运算,还能作为类型运算
:
let a = 1
let b: typeof a
if (typeof a === 'number') {
b = a
}
Ts 的 typeof 类型运算部分,在编译后,会被全部删除。
由于编译时不会进行 Js 的值运算,所以 Ts 规定,typeof 的参数只能是标识符,不能是需要运算的表达式:
type T = typeof Date() // 报错
上面示例会报错,原因是 typeof 的参数不能是一个值的运算式,而 Date()需要运算才知道结果。
另外,typeof 命令的参数不能是类型。
type Age = number
type MyAge = typeof Age // 类型别名不能作为操作数,报错
typeof 是一个很重要的 Ts 运算符,有些场合不知道某个变量 foo 的类型,这时使用 typeof foo 就可以获得它的类型。
块级类型声明
Ts 支持块级类型声明,即类型可以声明在代码块(用大括号表示)里面,并且只在当前代码块有效,同一作用域中,声明不允许重复。
if (true) {
type T = number
let v: T = 5
} else {
type T = string
let v: T = 'hello'
}
类型的兼容
Ts 的类型存在兼容关系,某些类型可以兼容其他类型。
type T = number | string
let a: number = 1
let b: T = a
上面示例中,变量 a 和 b 的类型是不一样的,但是变量 a 赋值给变量 b 并不会报错。这时,我们就认为,b 的类型兼容 a 的类型。
Ts 为这种情况定义了一个专门术语。
如果类型 A 的值可以赋值给类型 B,那么类型 A 就称为类型 B 的子类型(subtype)
,反之则是父类型
。
在上例中,类型 number 就是类型 number|string 的子类型。
Ts 的一个规则是,凡是可以使用父类型的地方,都可以使用子类型,但是反过来不行。
let a: 'hi' = 'hi'
let b: string = 'hello'
b = a // 正确
a = b // 报错
Ts 之所以有这样的规则和设计,是因为子类型继承了父类型的所有特征
,同时,子类型是父类型的扩展
,子类型还可能有一些父类型没有的特征
,所以子类型不可以赋值为父类型,父类型可以赋值为子类型
。
单用父子表述做理解类型兼容,偶有歧义,可以试着用集合论来做理解:
Ts 中,类型初始化确定后不会变更,如果 A 类型是 B 类型的延伸
,则 A 类型可以用在 B 类型的使用场合,而 B 类型不能用在 A 类型的使用场合。
数组类型
Js 数组在 Ts 中分为两种类型,分别是数组(array)和元组(tuple)。
在 Ts 中,数组所有成员的类型必须相同
,成员数量则可以是不确定的(基于这种设计,Ts 读取不存在的数组成员并不会报错)。
数组类型的两种写法。
第一种写法,类型后跟上数组方括号:
let arr: number[] = [1, 2, 3]
let arr2: (number | string)[] = [1, 2, '3'] // 用于联合类型数组
上面示例中,联合类型数组的括号是必须的,否则 Ts 就识别为 string 类型的数组了,对于不确定的数组类型,可以声明为any[]
。
第二种写法,使用 Ts 内置的Array接口
:
let arr3: Array<number> = [1, 2, 3]
let arr4: Array<number | string> = [1, 2, '3']
这种写法对于成员存在联合类型的数组,可读性较优,本质属于泛型
。
Ts 允许使用方括号读取数组成员的类型。
type Names = string[]
type Name = Names[0] // string
由于数组成员的索引类型都是 number,所以读取成员类型也可以写成下面这样:
type Names = string[]
type Name = Names[number] // string
数组的类型推断
Ts 会对未做类型声明的数组做类型推断,如果是空数组则推断为any[]
,同时会对空数组的后续更新做类型更新:
let arr5 = [1, 2, '3'] // Array<number | string>
let arr6 = [] // any
arr6.push(1) // 推断更新为 number[]
arr6.push('2') // 推断更新为 (number | string)[]
注意,类型推断的更新只针对空数组的情况,推断类型为初始值的类型,如果初始值不为空,后续不符合则会报错:
let arr7 = [1] // number[]
arr7.push('2') // 非空数组,推断类型为初始值的类型,后续不符合则会报错
只读数组,const 断言
Js 中,const 声明的数组变量成员是可变的(引用类型):
const arr = [0, 1]
arr[0] = 2
但是,有时候我们有声明固定数组(只读数组)的需求。
Ts 允许声明只读数组,写法是在类型声明前加上readonly
关键字:
const arr8: readonly number[] = [0, 1]
// 对只读数组做任何修改都会报错
arr8[1] = 2
arr8.push(3)
delete arr8[0]
arr8 = [1]
上面示例中,对于只读数组成员的任何增删改都会报错。
Ts 将readonly number[]
与number[]
视为两种不一样的类型,后者是前者的子类型,因为只读数组没有pop()
、push()
之类会改变原数组的方法,所以在 Ts 中,继承了父类型的所有特征,并加上了自己的特征,拥有更多特征的number[]
是readonly number[]
的子类型(详见类型的兼容、类型的断言):
let arr9: number[] = [1, 2, 3]
let arr10: readonly number[] = [1]
arr10 = arr9 // 正确
arr9 = arr10 // 报错
由于只读数组是数组的父类型,所以它不能代替数组。这一点很容易产生令人困惑的报错:
function getSum(s: number[]) {
// ...
}
const arr: readonly number[] = [1, 2, 3]
getSum(arr) // 报错
只读数组还有一种声明方法,就是使用const 断言
:
const arr13 = [0, 1, 2] as const
arr13[1] = 2 // 无法为“1”赋值,因为它是只读属性
多维数组
Ts 使用T[][]
的形式,表示二维数组,T 是最底层数组成员的类型:
const multi: number[][] = [
[1, 2, 3],
[23, 24, 25],
]
元组类型
元组(tuple
)是 Ts 特有的数据类型,表示成员类型可以自由设置的数组,即与数组类型的区分是元组的各个成员类型及数量可以不同
。
在 Ts 中,元组类型与数组类型的语法差异即是类型是声明于方括号内。
由于成员的类型可以不一样,所以元组必须明确声明每个成员的类型
。
const tuple: [number, string, string] = [1, '2', '3']
如不声明类型,则会被推断为数组类型
:
const arr = [1, 2, '3'] // (number | string)[]
元组可以通过在成员类型后添加后缀?
,表示该成员为可选
成员,同时,可选成员的声明必须在必选成员之后
:
const tuple2: [number, number?] = [1]
type TypeTuple = [number, number, string?, boolean?]
大多数情况下,元组成员数量是有限的,从类型声明即决定了元组成员的数量,超出越界则会报错。
可以使用扩展运算符...
,表示不限成员数量
的元组(为 0 也算),扩展运算符可以用在元组的任意位置,但可选成员不能位于扩展运算符之后:
type StrNums = [string, ...number[]]
const arr2: StrNums = ['1', 2, 3]
const arr3: StrNums = ['1', 2, 3, 4, 5]
type StrNums2 = [...number[], string]
const arr4: strNums2 = [1, 2, '3']
type StrNums3 = [boolean, ...number[], string]
const arr5: StrNums3 = [true, 1, '2']
const arr6: StrNums3 = [true, '2']
type ErrType = [...boolean[], string?] // 报错
元组成员可以添加成员名称,只用于说明备注
,无实际作用:
type User = [name: string, age: number, disabled: boolean]
const userArr: user = ['zhiyu', 18, false]
可以通过方括号,读取成员类型:
type Tuple = [string, number]
type Age = Tuple[0] // string
type Age = Tuple[number] // string | number,返回联合类型
对于复杂且不确定成员类型、数量的元组:
type Tuple = [...any[]] // 但这么写没啥意义
只读元组
元组也可以声明只读:
type RTuple = readonly [number]
type RTuple = Readonlu<[number]>
与前文数组一样,只读元组是元组的父类型,所以,可以使用只读元组类型的地方,都可以使用元组类型,反之则报错(子类型不能赋值为父类型,反之则可以):
type TFather = readonly [number]
type TSon = [number]
let s: TSon = [1]
let f: TFather = [1]
s = f // 报错
f = s
基于父子类型兼容问题,会产生一些报错:
function addNumber([n1, n2]: [number, number]): number {
return n1 + n2
}
let val = [1, 2] as const
addNumber(val) // 报错,只读元组不能替代元组
addNumber(val as [number, number]) // 使用类型断言解决
上面示例中,[1, 2] as const
的 const 断言写法,生成的是只读数组,同时也是只读元组。
因为它生成的实际上是一个只读的值类型readonly[1, 2]
。
成员数量推断
如果没有可选成员和扩展运算符,Ts 会推断出元组的成员数量进行提示:
function fun(arr: [number, number]) {
if (arr.length === 3) {
} // 报错
}
如果包含可选成员,Ts 会推断可能的成员数量:
// length: 1 or 2 or 3
function fun2(arr: [number, number?, number?]) {
if (arr.length === 4) {
} // 报错
}
如果使用了扩展运算符,Ts 则无法正确推断:
const myTuple: [...string[]] = ['a', 'b', 'c']
if (myTuple.length === 4) {
// 正确
}
这是因为 Ts 内部会把使用扩展运算符的元组当作数组处理
,而数组成员并不限制数量。
扩展运算符与成员数量
扩展运算符...
会将数组(不是元组)转换为,
分隔的参数序列,这时,Ts 会认为该序列的成员数量不确定,如果此时进行函数入参调用,可能会导致参数长度不匹配的报错:
function fun3(x: number, y: number) {}
const arrVal = [1, 2]
fun3(...arrVal) // 报错
fun3(...[1, 2]) // 当然,直接传值Ts就明确,不会报错
解决方法即是,使用元组,这样 Ts 就明确成员个数了:
function fun4(x: number, y: number) {}
const arrVal2: [number, number] = [1, 2]
fun3(...arrVal2) // 报错
或 const 断言:
const arr = [1, 2] as const
这样 Ts 就会认为这是个只读数组,成员数量并不会发生改变。
有些函数可以接受任意数量的参数,这时使用扩展运算符就不会报错:
const arr = [1, 2, 3]
console.log(...arr) // 正确
函数类型
函数的类型声明,在声明函数时,给到参数的类型及返回值的类型。
function helloTs(txt: string): void {
// void代表函数没有返回值,详见后文
console.log('helloTs', txt)
}
返回值类型通常可以不写,因为 Ts 会根据 return 自动推断。
如果不指定函数的参数类型,Ts 会自动推断,通常会推断为any
。
如果一个变量被赋值为函数,变量的类型可以有两种语法:
// 语法一
const fn = function (txt: string) {}
// 语法二
const fun2: (txt: string) => void = function (txt) {}
上面示例中,语法二需要注意函数类型的参数名是必须的,直接写类型会导致 Ts 把它识别为参数名(等于不声明类型,推断成any)
。
在 Ts 中,推荐使用type
命令定义一个类型别名,指定给变量使用,便于复用多个函数,同时,在 Ts 中,类型定义的参数名可以与实际参数名不一致:
type Fn = (a: number, b: number) => number
const fun3: Fn = (x, y) => x + y
Ts允许省略参数
,即函数的实参个数,可以少于类型指定的形参个数,但不能多于:
type Fn = (a: number, b: number) => number
const fun4: Fn = (x) => x
const fun5: Fn = (x, y, z) => x // 报错
这是因为在 Js 的设计中,函数往往有多余非必传
的参数,如数组forEach()
方法,该函数有三个形参(item, index, array) => void
,但实际使用,通常只传第一个参数最多,所以,Ts 允许省略参数。
let x = (a: number) => 0
let y = (a: number, b: number) => 0
x = y // 报错
y = x
上面示例中,函数 x 只有一个参数,函数 y 有两个参数,x 可以赋值给 y,反过来就不行(类似于父子类型的规则)。
在 Ts 中,如果一个变量要套用
另一个函数的类型,可以使用一个小技巧,即使用typeof
运算符做类型运算
:
function fun5(num: number): number {
return num
}
const fun6: typeof fun5 = (num) => num // 使用typeof运算符返回函数fun5的类型
函数类型还可以采用对象写法
:
const add: {
(a: number, b: number): number
v: string
} = (a, b) => a + b
add.v = '1.0.0'
// 语法
// {
// (参数): 返回值
// }
注意,这种写法的函数与参数返回值,间隔符是:
,而不是=>
,因为这里是对象写法,键值依照对象声明规则采用冒号分割。
这种写法通常用于函数本身存在属性
,此时类型就要使用对象语法。
函数类型也可以使用interface
命令定义:
interface Fn {
(a: number, b: number): number
}
Function 类型
Ts 提供了Function
类型,所有函数都属于这个类型:
function fn7(f: Function) {
return f()
}
Function 类型的函数可以接受任意数量、类型的参数及任何返回值,也就是全部都是any
,故不建议使用。
箭头函数
箭头函数的类型写法与普通函数类型类似:
const arrow = (str: string): string => str.replace('s', 'S')
上面示例中,参数类型写在函数定义中,返回值写在):
后。
注意,类型写在箭头函数定义
里,与使用箭头函数表示函数类型,写法有所不同,返回值要写在=>
右侧,而不是在):
后:
const arrow2 = (fn: (n: number) => number): void => {
fn(1)
}
再来看箭头函数定义类型时可能的语法错误:
type Person = { name: string }
const people = ['lb', 'cc', 'sq'].map((name): Person => ({ name }))
上面示例中,Person 类型别名定义用于 map 回调的返回值,如果(name)
没加括号,则 Person 成了形参 name 的类型定义,如果{ name }
不加上()
,则{}
会被当函数体解析,name 则成了返回值,不符合 type 定义。
可选参数
如果函数的某个参数非必选
,则可在参数名后加?
表示可省略:
function fun8(a?: number) {}
上面示例中,参数 a 为可选参数,在 Ts 的处理中,其实该参数的类型被声明为了number | undefined
,所以该参数可以不传:
function fun8(a?: number) {}
fun8(undefined) // 不会报错
但是,如果显式声明为number | undefined
,则不能省略:
function fun9(a: number | undefined) {}
fun9() // 报错
同样的,可选参数也只能在参数列表末尾声明,声明于必选参数后面:
type Tfn = (x?: number, y: number) => void // 报错
如果前面参数可能为空,建议声明该参数类型为number | undefined
,同时传入'undefined'。
对于可选参数,在函数内部应做好防御判断是否为空:
type Tfn2 = (x: number, y?: number) => number[]
const fun11: Tfn2 = (x, y) => {
if (y === undefined) {
return [x]
}
return [x, y]
}
参数默认值
Ts 函数中的参数默认值声明与 Js 一致:
const defaultValue = (x: number = 1) => x
defaultValue() // 1
上面示例中,甚至可以直接省略参数的类型,因为默认值已经给到 Ts 做类型推断了。
可选参数不能与默认值同时使用
:
const defaultValue2 = (x?: number = 1) => x // 报错
注意,默认值是写在函数定义
中,而不能写在类型中:
type Tfn = (x: number = 1) => number[] // 报错
const defaultValue: Tfn = (x = 1) => [x]
注意,在 Ts 中,具有默认值的参数若不位于参数列表末尾或传入undefined
,调用时不能省略:
const defaultValue4 = (x: number = 1, y: number) => x
defaultValue4() // 报错
defaultValue4(undefined, 2)
参数解构
函数参数如果存在变量解构,类型语法如下,建议使用type
声明类型别名使用,提高代码整洁度:
function obj({ a, b, c }: { a: number; b: number; c: number }) {}
function arr([x, y]: [number, number]) {}
type ABC = { a: number; b: number; c: number }
function obj2({ a, b, c }: ABC) {}
剩余参数
函数剩余参数,它可以是数组(类型相同),也可以是元组(类型不同),据剩余参数类型而定:
function nums(...nums: number[]) {}
function args(...args: [number, boolean]) {}
注意,元组需要标注每个剩余参数的类型,如参数可选,需使用可选参数:
function args(...args: [number, boolean?]) {}
剩余参数甚至支持嵌套:
function args3(...args: [boolean, ...string[]]) {}
readonly 只读参数
如果函数入参需要定义只读,不允许在函数内修改,可以加上readonly
,表示为只读参数:
function readonlyValue(arr: readonly [number]) {
arr[0] = 1 // 报错
}
注意,readonly
关键字在函数中只能定义于数组和元组类型参数,如用于其他类型,则会报错。
void 类型
void 类型表示函数没有返回值
:
function voidFn(): void {
console.log('void')
}
如果定义返回值为void
类型后仍返回其他类型,则会报错:
function voidFn2(): void {
return 123 // 报错
}
void 类型允许返回值为 undefined 或 null
:
function rUndefined(): void {
return undefined
}
function rNull(): void {
return null
}
如果打开了编译选项strictNullChecks
的话,那 void 只允许返回undefined
,其他则报错,这是因为,在 Js 中,没有返回值就等同于返回undefined
。
需要特别注意的是,如果变量、对象方法、函数参数
是一个返回值为 void 类型的函数,那么并不代表不能赋值为有返回值的函数。反相恰恰,该变量、对象方法和函数参数可以接受返回任意值的函数,这时并不会报错:
type VoidFn3 = () => void
const f: VoidFn3 = () => {
return 123
}
这是因为 Ts 认为,这里的void
仅代表该函数返回值没有利用价值,这说不应该使用该返回值。只要不用到这里的返回值,就不会报错。
这样设计是有现实意义的。举例来说,数组方法Array.prototype.forEach(fn)
的参数 fn 是一个函数,而且这个函数应该没有返回值
,即返回值类型是 void。 但是实际使用中,我们传入的函数是有返回值的,但是返回值并不重要,或者说不产生作用:
const numArr = []
;[1, 2, 3].forEach((item) => numArr.push(item))
上面示例中,push()
方法是有返回值的,表示插入元素后数组的长度。但对于forEach()
方法来说,这个返回值没有意义,所以 Ts 不会报错,但如果后期使用这个函数的返回值,则会报错:
type VoidFn3 = () => void
const f: VoidFn3 = () => {
return 123
}
f() * 2 // 报错
注意,这种情况仅限于变量、对象方法和函数参数
,函数字面量
如果声明了返回值是 void 类型,还是不能有返回值。
函数的运行结果如果是抛出了错误,也允许将返回值写成 void:
function tErr(): void {
throw new Error('error')
}
除了函数,其他变量声明类型为 void 约等于脱裤子放屁,因为此时只能赋值为undefined
或null
(当然,打开了strictNullChecks
配置项的话,只允许undefined
):
let foo: void = undefined
never 类型
never
类型表示肯定不会出现的值
,作用于函数返回值则表示该函数肯定不会正常执行结束
(连 undefined 返回值都没有)。
它主要有以下两种情况:
1.抛出错误的函数
function fail(): never {
throw new Error('error')
}
上面示例中,函数会抛出错误,不会正常退出。
注意,只有抛出错误才适用 never 类型,如果 return 一个Error对象
,则返回值应该是 Error 类型:
function fail2(): Error {
return new Error('Something failed')
}
另外,由于抛出错误的情况属于 never 类型或 void 类型,所以无法从返回值类型中获知,抛出的是哪一种错误。
2.无限执行的函数
如果一个函数抛出了异常或者陷入了死循环,那么该函数无法正常返回一个值,因此该函数的返回值类型就是 never。
const sing = function (): never {
while (true) {
console.log(123)
}
}
上面示例中,函数永远执行,永远不会返回,所以返回值类型是 never。
注意,never 类型不等同于 void 类型,前者表示函数没有执行结束,不可能有返回值,后置表示函数正常执行结束,但是不返回值,或者说返回undefined
:
function fn(): void {
console.log(123)
}
// 报错
function fn(): never {
console.log(123)
}
上面示例中,函数虽然没有 return 语句,但实际返回了undefined
,所以,返回值写为 never 类型会报错。
一个函数如果某些条件下有正常返回值,另一些条件下抛出错误,这时它的返回值类型可以省略 never:
function sometimesThrow(): number {
if (Math.random() > 0.5) {
return 100
}
throw new Error('Something went wrong')
}
上面示例中,函数sometimesThrow()
的返回值其实是number|never
,但是一般都写成number
。
原因是 never 类型是唯一的一个底层类型,其他所有类型都包括了 never
。
从集合论的角度看,number|never
等同于number
。这也提示我们,函数的返回值无论是什么类型,都可能包含了抛出错误的情况,详见前文never 类型。
如果在函数中调用了一个返回值类型为 never 的函数,那就意味着该函数执行过程会在调用该函数时终止,永远不会执行后续代码:
function neverReturns(): never {
throw new Error()
}
function fn(x: string | undefined) {
if (x === undefined) neverReturns()
x // 推断为string
}
上面示例中,函数fn()
的参数 x 的类型为string|undefined
,但是,x 类型为undefined
时,调用了neverReturns()
,这个函数不会返回,因此 Ts 可以推断出,判断语句后面的那个 x,类型一定是string
。
函数重载
有些函数可以接受不同类型、数量的参数,根据参数的不同有不同的函数行为
。这种根据参数不同,执行不同逻辑,有多套内部逻辑的函数,我们称之为函数重载(function overload)
。
在 Ts 中,函数重载的实现分为两个部分:
- 重载签名:定义函数的参数和返回类型,不包含实现。
- 实现签名:函数的具体实现,包含完整类型声明或
宽泛
类型(any)。
注意,重载函数的重载签名与实现之间,不能有其他代码,否则报错。
function reverse(str: string): string
function reverse(arr: any[]): any[]
function reverse(strOrArr: string | any[]): string | any {
if (typeof strOrArr === 'string') {
return strOrArr.split('').reverse().join('')
} else if (Array.isArray(strOrArr)) {
return strOrArr.reverse()
}
throw new Error('没有与此调用匹配的重载')
}
reverse('1234') // 4321
reverse([1, 2, 3, 4]) // [4,3,2,1]
上面示例中,第一行和第二行为函数的重载签名,第三行开始为函数的实现,包含所有类型入参和返回的完整类型声明,此时,调用函数类型会提示(+1 overload)
,代表重载的实现。
有一些编程语言允许不同的函数参数对应不同的函数实现,但是在Js中,函数的实现只能有一个
,必须在这个函数中,处理不同的参数逻辑,因此函数体内部就需要判断参数的类型及数量,并根据判断结果执行不同的逻辑。
另外,虽然函数实现中有完整的类型声明,但函数实际调用类型以重载签名为准
,上面示例中的函数实现参数类型和返回值类型都是string | any[]
,但不意味着参数类型为string
时返回值类型为any[]
。
函数重载的每个类型声明之间,以及类型声明与函数实现的类型之间,不能存在冲突:
function fn(x: boolean): void
function fn(x: string): void // 此重载签名与其实现签名不兼容
function fn(x: boolean | number) {}
注意,重载签名的排序也很重要
,因为 Ts 是逐行检查的,一旦找到符合的类型声明,就不往下检查了,所以,宽泛的类型应该往后放
,防止覆盖其他类型声明:
type ZeroOrOne = 0 | 1
function fn(x: any): number
function fn(x: number): ZeroOrOne
function fn(x: any): any {}
const a: ZeroOrOne = fn(1) // 报错无法分配number => zeroOrOne,匹配了第一个重载签名,需要对调两个重载签名
由于重载的类型声明较为复杂,一般情况下,优先使用联合类型替代
,除非重载的声明可以更直观缕清
复杂函数的对应入参和返回之间的关联:
// 函数重载
function len(s: string): number
function len(arr: any[]): number
function len(x: any): number {
return x.length
}
// 联合类型
function len(x: any[] | string): number {
return x.length
}
对象的方法也可以使用函数重载:
class strArr {
#data = ''
add(num: number): this
add(bool: boolean): this
add(value: number | boolean): this {
// ...
return this
}
}
函数重载也可以用来精确描述函数参数与返回值之间的对应关系:
function createElement(tag: 'a'): HTMLAnchorElement
function createElement(tag: 'canvas'): HTMLCanvasElement
function createElement(tag: 'table'): HTMLTableElement
function createElement(tag: string): HTMLElement {
// ...
}
上面示例中的函数重载也可以使用对象表示:
type CreateElement = {
(tag: 'a'): HTMLAnchorElement
(tag: 'canvas'): HTMLCanvasElement
(tag: 'table'): HTMLTableElement
(tag: string): HTMLElement
}
构造函数
Js 语言使用构造函数生成对象的示例,构造函数使用new
命令声明。
构造函数的类型写法,就是在参数列表前加上new
命令:
class constructor {}
type FnConstructor = new () => constructor
function create(c: FnConstructor): constructor {
return new c()
}
create(constructor)
构造函数还可以使用对象写法,同时,构造函数也可作为普通函数使用,类型声明如下:
type F = {
new (s: string): string
(n?: number): number
}
对象类型
对象是 Js 的一种引用类型,Ts 对与对象类型有很多规则。
基础的对象类型声明:
const obj: { x: number; y: number } = { x: 1, y: 2 }
type UserObj = {
name: string
age: number
}
属性类型的声明可以用;
,,
结尾,也可以不写。
一旦声明了类型,就不能增多缺少或删除某个属性
,对于不存在的属性读取,Ts 也会报错:
// 对象类型
const obj: { x: number; y: number } = { x: 1, y: 2 }
type UserObj = {
name: string
age: number
}
delete obj.y // 报错,需要打开strictNullChecks
obj.c // 报错
对象中的方法
使用函数类型描述:
const objFn: {
fn(num: number): number
} = {
fn(num) {
return num
},
}
可以使用方括号读取对象属性的类型:
type User = {
name: string
}
type name = User['name'] // name => string
除了type
命令,还可以使用interface
命令,将对象类型提炼为一个接口:
interface User2 {
name: string
age: number
}
Ts 不区分对象的继承属性和自身属性,一律视为对象属性:
const obj: {
toString(): string
name: string
} = { name: 'zhiyu' } // 并不会报错toString未声明,因为它是继承的
可选属性
如果属性是可选(可忽略、删除)的,需要在属性名后加一个?
:
const obj: {
name: string
age?: number
} = {
name: 'zhiyu',
age: 18,
}
delete obj.age // 报错
obj.age = undefined
上面示例中,打开strictNullChecks
配置项后,删除可选参数也不会报错,同时,可选参数也可以赋值为undefined
,同样的,读取一个未赋值的可选属性,会返回undefined
,所以,在 Ts 中,读取可选参数前,最好做一下类型判断:
// ...接上方示例
if (obj.age !== undefined) {
//...
}
let age = obj4?.age ?? 18
Ts 还提供了ExactOptionalPropertyTypes
配置项,只要同时打开该配置与strictNullChecks
配置项,可选属性就不能设为undefined
,不能删除。
注意,可选属性与允许设为undefined
的必选属性是不等价的,联合属性number | undefined
,不代表该属性可忽略:
type ObjT = {
x: any
y: number | undefined
}
const obj: ObjT = { x: 1 } // 报错,number | undefined 不等价于可选属性
只读属性
属性名前加上readonly
关键字,表示该属性是只读属性,只允许在初始化时赋值,不允许修改:
const obj5: {
readonly x: number
} = { x: 1 }
obj5.x = 2 // 报错
注意,如果属性值是一个对象,那readonly
并不会禁止该对象属性的修改,而只是禁止替换整个属性:
const obj: {
readonly user: {
name: string
}
} = {
user: {
name: 'zhiyu',
},
}
obj.user = { name: 'zhiyu2' } // 报错
obj.user.name = 'zhiyu2'
注意,如果一个只读对象是对另一个对象的引用
,那此时修改属性,Ts 并不会报错:
interface User3 {
name: string
}
interface User4 {
readonly name: string
}
let x: user3 = { name: 'zhiyu' }
let y: user4 = x
x.name = 'zhiyu2'
console.log(y.name) // 'zhiyu2'
除了readonly
关键字,还可以使用只读断言声明只读:
const obj7 = { name: 'zhiyu' } as const
const obj8: { name: string } = { name: 'zhiyu' } as const
obj8.name = 'zhiyu2' // 正确
上面示例中,obj8
可以修改变量属性,这是因为,as const
属于类型推断,如果变量明确声明了类型,那变量会以声明的类型为准
。
属性名的索引类型
如果属性名非常多,导致分别声明类型十分繁琐,这时 Ts 允许使用属性名表达式的写法来描述类型,即属性名的索引类型
。
最常见的字string
索引,[property: string]
表示属性名:
type StrKey = {
[property: string]: number
}
const obj9: StrKey = {
a: 1,
b: 2,
c: 3,
d: 4,
}
索引类型的key
,还存在number
、symbol
类型:
type NumKey = {
[property: number]: string
}
type SymbolKey = {
[property: symbol]: string
}
const obj10: NumKey = {
0: 'success',
1: 'error',
}
对象还可以同时拥有多种类型的属性名索引
,如同时拥有数值索引和字符串索引。但是数值索引的值不能和字符串索引的值发生冲突
,必须以字符串索引
为准。
这是因为,在 Js 中,所有的数值属性名最后都会转为字符串属性名:
type Type = {
[x: number]: boolean // 报错,需改对象值类型为string
[x: string]: string
}
此外,还可以既声明属性名索引,又声明单个属性名,同样的,如果单个属性名不符合属性名索引的类型定义,两者就会冲突,报错:
type Type = {
age: number // 报错
[x: string]: string
}
属性名的索引类型还可以用来声明数组,不过有点反直觉,不建议使用
:
type ArrT = {
[n: number]: number
// length: number // 加上就不会报错
}
const arr: ArrT = [1, 2, 3]
arr.length // 报错,因为类型未定义length类型
属性名的索引类型写法建议不用,因为声明宽泛,约束又少。
解构赋值
解构赋值用于从对象中提取属性。
解构赋值的类型语法与对象声明类型一致:
const { id, user }: { id: number; user: string } = { id: 1, user: 'name' }
注意,目前无法为解构的变量直接指定类型,因为这与 Js 解构变量重命名的语法冲突
:
let { x: foo, y: bar } = obj
结构类型原则
只要对象 B 满足对象 A 的结构特征,Ts 就认为对象 B 兼容对象 A 的类型,这称为结构类型
原则(structural typing):
const B: {
x: number
y: number
} = {
x: 1,
y: 1,
}
const A: { x: number } = B // 正确
上面示例中,A 和 B 并不是同一个类型,但是 B 可以赋值给 A,因为B满足A的结构特征
。
Ts 之所以这样设计,是因为 Js 的行为,Js 并不关心对象是否严格相似,只要某个对象具有所要求的属性,就可以正常运行,所以 Ts 检查某个值是否符合指定类型时,并不是检查这个值的类型名(名义类型),而是检查这个值的结构是否符合要求(结构类型)。
上面示例中,B 拥有符合 A 结构类型的属性,同时拥有 A 没有的类型,是 A 类型的延伸,根据类型兼容原则,A 是 B 的父类型,子类型不能赋值为父类型,而父类型可以赋值为子类型
。
这个特性还会产生一些意想不到的错误:
type MyObj = {
x: number
y: number
}
function getSum(obj: MyObj) {
let sum = 0
for (const n of Object.keys(obj)) {
const v = obj[n] // noImplicitAny配置 报错
sum += Math.abs(v)
}
return sum
}
上面示例中,函数要求传入的对象属性类型都是 number,但因为结构类型原则的存在,实际上所有与参数类型兼容的对象都可以传入,这会导致遍历对象中的变量v
的类型推断为any
。如果项目同时开启了noImplicitAny
配置项禁止隐式声明any
类型,那代码就会一直报错,除非在函数中只使用类型声明中的对象属性,从而证对象属性的类型不会被兼容对象的属性污染
:
function getSum(obj: myObj) {
return Math.abs(obj.x) + Math.abs(obj.y)
}
严格字面量检查
如果变量赋值的对象为字面量
(直接量、固定值,所见即所得),则会触发 Ts 的严格字面量检查
(strict object literal checking),如果字面量的结构与类型定义不一致(存在多余属性),就会报错,但如果赋值的不是字面量,而是一个变量,则根据结构类型原则,不会报错:
type MyObj = { x: number; y: number }
const obj11: MyObj = {
x: 1,
y: 1,
z: 1, // 报错
}
let obj12: MyObj
const obj13 = {
x: 1,
y: 1,
z: 1,
}
obj12 = obj13 // 不会报错,兼容类型,符合结构类型原则
Ts 对字面量做严格检查的原因,主要是为了防止拼写错误
,一般字面量大多来自手动声明,容易拼写错误。
想要规避严格字面量检查,可以如前文示例,使用中间变量
,通过结构类型原则规避,或使用类型断言as myObj
,当然,一般这种场景不建议这么干。
在 Ts 的编译配置中,可以启用suppressExcessPropertyErrors
配置项,关闭对象的多余属性检查。
如果配置项字面量允许多余属性,可以定义一个属性名索引类型:
type FooT = { foo: number; [x: string]: any }
const foo: FooT = { foo: 1, off: 2 }
最小可选属性规则
根据结构类型原则,如果一个对象所有属性都是可选
的,那它将兼容其他所有对象:
type Options = {
a?: number
b?: number
c?: number
}
上面示例中,所有属性都是可选属性,这意味着其他任意对象都能满足该类型的结构。
为了避免这种情况,Ts 在 2.4 版本引入了最小可选属性规则
,也称为弱类型检测:
type OptT = {
a?: number
b?: number
c?: number
}
const tmpObj = { d: 1 }
const opt: OptT = tmpObj // 报错
上面示例中,对象 opt 没有与 OptT 类型的共同属性,赋值会报错。
这是因为,如果某个类型的所有属性都为可选,那么该类型的对象至少需要存在一个可选属性,不能所有可选属性都不存在,这就是最小可选属性规则
。
如果想规避该规则,可添加索引类型[property:string]:number
,或使用类型断言。
空对象
空对象是 Ts 的一种特殊值,也是一种特殊类型。
const obj = {}
obj.a = 1 // 报错
上面示例中,变量 obj 的值是一个空对象,对其赋值属性会导致报错,这是因为,Ts 实际推断变量 obj 的类型为空对象,即const obj: {}
。
空对象不存在自定义属性
,对其做自定义属性赋值则会报错。
空对象只能使用继承属性
(继承自原型的方法等),即继承自原型对象Object.prototype
的属性:
obj.toString() // 正确
在 Js 中,先声明一个空对象,然后再依次添加属性是很常见的。但是,在 Ts 中,是不允许动态添加属性的,必须在初始化时一次性声明符合类型定义、类型推断的所有属性:
// 报错
const pt = {}
pt.x = 1
pt.y = 2
// 正确
const pt = { x: 1, y: 2 }
如果确实需要分步声明,可以使用扩展运算符
合并为新对象,也符合 Ts 静态声明
的要求:
const pt0 = {}
const pt1 = { x: 1 }
const pt2 = { y: 2 }
const pt = { ...pt0, ...pt1, ...pt2 }
空对象作为类型,其实是Object
类型的简写形式:
let d: {}
// 等同于
let d: Object
d = 1
d = true
d = { x: 1 }
上面示例中,除了null、undefined
,都可以赋值给空对象(除了自定义属性),跟Object
类型行为一致。
因为Object
类型无所不包,而空对象又是Object
类型的简写,所以它没有有严格字面量检查
,总是允许赋值多余属性,但不能读取这些属性:
interface Empty {}
const emptyObj: Empty = { x: 1, y: 2 } // 正确
emptyObj.x // 报错不存在属性x
如果想强制使用没有任何属性的对象,可以这么写:
interface WithoutProperties {
[key: string]: never // 表示字符串属性名是不存在的
}
const obj: WithoutProperties = { x: 1 } // 如何赋值都会报错
interface 接口
interface
(接口)是对象的模板,是一种类型约定,使用了该接口的对象,就拥有了指定的类型结构。
interface 可以表示对象的各种语法,它的成员有五种形式:
对象的属性
interface Person { firstName: string lastName?: string // 可选属性 readonly age: number // 只读属性 }
通过方括号还可以读取
interface
某个属性的类型:type age = Person['age']
对象的索引属性
interface Person3 { [prop: string]: number // 对象索引属性 }
索引属性的规则及限制与前文属性名的索引类型一致。
对象的方法
interface 声明对象的方法的语法有三种:
interface A { f(x: number): number } interface B { f: (x: number) => number } interface C { f: { (x: number): number } }
属性名支持表达式:
const key = 'f' interface D { [key](x: number): number }
方法也支持重载,需要注意的是,在 interface 里的函数重载不需要给出实现(定义类型),但是对象内部定义方法又无法使用函数重载的语法,所以还需要在对象外部
额外定义
函数的重载签名和实现签名(较为繁琐):interface A { f(): number f(x: boolean): boolean f(x: string, y: string): string } function MyFunc(): number function MyFunc(x: boolean): boolean function MyFunc(x: string, y: string): string function MyFunc( x?: boolean | string, y?: string ): number | boolean | string { if (x === undefined && y === undefined) return 1 if (typeof x === 'boolean' && y === undefined) return true if (typeof x === 'string' && typeof y === 'string') return 'hello' throw new Error('wrong parameters') } const a: A = { f: MyFunc, }
函数
interface Fn { (x: number): number } const fn: Fn = (x) => x
构造函数
interface 内部可以使用 new 关键字,表示构造函数:
interface ErrorConstructor { new (message?: string): Error }
interface 的继承
interface 可以继承
其他类型,主要有三种情况:
1.interface 继承 interface
interface 可以使用extends
关键字,继承其他 interface,并允许多重继承
:
interface Name {
name: string
}
interface Age {
age: number
}
interface Disabled extends Age {
disabled: boolean
}
interface User extends Name, Disabled {}
const user: User = {
name: '12',
age: 18,
disabled: true,
}
上面示例中,子接口 Disabled 继承了父接口 Age,最后 User 接口又多重继承
了 Disabled、Name 接口,拥有了三个属性。
多重继承,实际上相当于多个父接口的合并
。
注意,如果子接口与父接口存在同名属性,则子接口会覆盖父接口的属性,同时,子接口与父接口的同名属性必须是类型兼容的
,不能有冲突,否则会报错:
interface Foo {
foo: string
}
interface Foo2 extends Foo {
// 报错
foo: number
}
在多重继承时,也需要注意多个父接口之间是否存在同名且类型冲突的属性。
2.interface 继承 type
inteface 支持继承type
命令定义的对象类型
:
type X = {
x: number
}
interface Xy extends X {
y: number
}
同样的,也需要注意继承时同名属性的类型冲突问题。
3.interface 继承 class
interface 还可以继承 class,即继承该类的所有成员:
class E {
x: string = ''
y(): boolean {
return true
}
}
interface E2 extends E {
z: number
}
const e2: E2 = {
x: '',
y: function () {
return true
},
z: 123,
}
某些类拥有私有成员和保护成员,interface 可以继承,但意义不大,对象并无法实现这些成员。
接口合并
接口合并,多个同名的interface会合并成一个interface
:
interface BOX {
width: string
}
interface BOX {
height: string
}
上面示例中,两个同名的 BOX 接口最终会合并为一个同时拥有
两个属性的接口。
这样设计主要是为了兼容 Js 的行为,对于开发者来说,常常会基于外部库或全局对象二次开发添加自定义属性和方法,那么,只要使用 interface 再次声明同名接口,定义自定义属性和方法的类型,就可以自动跟原始 interface 合并,使得拓展外部类型十分方便
。
举例来说,Web 网页开发经常会对 window 对象和 document 对象添加自定义属性,但是 Ts 会报错,因为原始定义没有这些属性
。解决方法就是把自定义属性写成 interface,合并进原始定义:
interface Document {
foo: string
}
document.foo = 'hello'
接口合并也要遵循兼容原则
,多个接口中的同名属性必须是类型兼容的,彼此不能互相冲突
。
多个接口合并时,如果同名方法有不同的类型声明,则会触发函数重载,并且,靠后定义的接口优先级更高(相当于重载签名更靠前,入栈式):
interface Cloner {
clone(animal: Animal): Animal
}
interface Cloner {
clone(animal: Sheep): Sheep
}
interface Cloner {
clone(animal: Dog): Dog
clone(animal: Cat): Cat
}
// 等同于
interface Cloner {
clone(animal: Dog): Dog
clone(animal: Cat): Cat
clone(animal: Sheep): Sheep
clone(animal: Animal): Animal
}
注意,这个规则还存在一个例外,同名方法中,如果一个函数参数的类型是字面量(值类型),则字面量类型具有更高优先级(具体 > 宽泛):
interface A {
f(x: 'foo'): boolean
}
interface A {
f(x: any): void
}
// 等同于
interface A {
f(x: 'foo'): boolean
f(x: any): void
}
一个实际的例子是 Document 对象的 createElement()方法,它会根据参数的不同,而生成不同的 HTML 节点对象。参数为字面量的类型声明会排到最前面,返回具体的 HTML 节点对象,类型越不具体的参数,排在越后面,返回通用的 HTML 节点对象:
interface Document {
createElement(tagName: any): Element
}
interface Document {
createElement(tagName: 'div'): HTMLDivElement
createElement(tagName: 'span'): HTMLSpanElement
}
interface Document {
createElement(tagName: string): HTMLElement
createElement(tagName: 'canvas'): HTMLCanvasElement
}
// 等同于
interface Document {
createElement(tagName: 'canvas'): HTMLCanvasElement
createElement(tagName: 'div'): HTMLDivElement
createElement(tagName: 'span'): HTMLSpanElement
createElement(tagName: string): HTMLElement
createElement(tagName: any): Element
}
两个接口组成的联合类型若存在同名属性,那么该属性的类型也是联合类型:
interface Circle {
area: bigint
}
interface Rectangle {
area: number
}
let cr: Circle | Rectangle
interface 与 type 的异同
interface 与 type 的作用类似,都可以用于表示对象类型。
interface 与 type 的区别主要是以下几点:
interface 只能表示对象类型(包括数组、函数),而 type 还能够表示非对象类型。
interface 可以继承其他类型,而 type 不支持继承。
继承主要用于添加属性,type 如果想要为定义的对象类型添加属性,只能使用
&
运算符(表示同时具备两个特征)重新定义
:type Animal = { name: string } type Bear = Animal & { honey: boolean }
而 interface 可以使用
extends
继承属性, interface 还可以继承 type:interface Foo { x: number } interface Bar extends Foo { y: number }
type 也可以继承 interface:
interface Foo { x: number } type Bar = Foo & { y: number }
同名 interface 会自动合并,自动添加属性,而同名 type 则会报错。
interface 不能包含属性映射(mapping),而 type 可以,详见后文类型映射:
interface Point { x: number y: number } // 正确 type PointCopy1 = { [Key in keyof Point]: PointKey } // 报错 interface PointCopy2 { [Key in keyof Point]: PointKey }
this
关键字只能用于 interface:interface ThisFoo { add(num: number): this } type ThisFoo2 = { add(num: number): this //报错 }
type 可以使用交叉类型
扩展原始数据类型
,interface 不行:// 正确 type MyStr = string & { type: 'new' } // 报错 interface MyStr extends string { type: 'new' }
interface 无法用于表达某些复杂类型(交叉、联合类型):
type A1 = {} type B1 = {} type AorB = A | B type AorBAndName = AorB & { name: string }
综上所述,type 适用于复杂
的类型运算,而 interface 则灵活
程度高,适用于扩展及合并,建议优先使用。
泛型
泛型(generics),允许在编写函数、类或接口时,不需要预先指定类型,而是定义占位符类型参数
,并在调用时传入具体的参数类型
。
有些时候,函数返回值与参数类型相关,根据参数决定返回值,这会导致类型声明无法体现参数与返回值的类型关系
:
function getFirst(arr) {
return arr[0]
}
// 类型声明
function f(arr: any[]): any {
return arr[0]
}
这时候,我们就需要泛型,泛型的特点即是,带有类型参数
(type parameter):
function getArrFirst<T>(arr: T[]): T {
return arr[0]
}
getArrFirst<number>([1, 2, 3])
上面示例中,函数名后的<T>
即是类型参数
,参数与返回值类型也是<T>
,可以直观的看到他们的类型关系
。
调用时,参数类型也可以不提供,由 Ts 做类型推断
:
getArrFirst([1, 2, 3]) // function getArrFirst<number>(arr: number[]): number
注意,并不是什么场景都可以忽略,对于复杂的使用场景
,则需要显式地声明参数类型:
function mergeArr<T>(arr: T[], arr2: T[]): T[] {
return [...arr, ...arr2]
}
mergeArr([1, 2], ['3', '4']) // 报错,因为推断冲突
mergeArr<number | string>([1, 2], ['3', '4'])
上面示例中,未提供参数类型时,参数类型推断为number
,这导致后续传入string[]
类型数组报错,故需要传入一个联合类型。当然,也可以定义多个类型参数,见下文。
类型参数的名字可以随便取,但必须为合法的标识符。
类型参数的第一个字符往往采用大写字母,一般使用T
(也可以使用语义化的命名,如Tkey
等),如果存在多个类型参数,则使用 T 后的 U、V 等字母,多个参数间使用,
分隔:
function mergeArr2<T, U>(arr: (T | U)[], arr2: (T | U)[]): (T | U)[] {
return [...arr, ...arr2]
}
mergeArr2<number, string>([1, 2], ['3', '4'])
总之,泛型可以理解为,声明了一段类型逻辑,需要通过类型参数来表达,从而在输入类型与输出类型之间建立对应关系
。
泛型的语法
泛型主要用于函数、接口、类和别名四个场景。
函数的泛型语法
// 普通函数形式
function getArrFirst<T>(arr: T[]): T {
return arr[0]
}
// 变量形式
let getId: <T>(num: T) => T = (num) => num
// 变量,对象形式
let getId2: { <T>(num: T): T } = (num) => num
getId<number>(123)
接口的泛型语法
interface User<T> {
name: T
}
let box: User<string> = {
name: '213',
}
泛型接口还有第二种语法,可以把类型参数定义于某个方法中:
interface Fn {
<T>(age: T): T
}
function num<T>(num: T): T {
return num
}
let myFn: Fn = num
上面示例中,Fn 的类型参数 T 的具体类型需要在 myFn 函数使用时提供,赋值不需要给出类型。
第二种语法的存在差异,它的类型参数定义与某个方法中,其他属性、方法无法使用该类型参数
,不像第一种语法,接口内部的所有属性方法都能使用该类型参数。
类的泛型写法
class A<K, V> {
key: K
value: V
}
继承泛型类时,需要给出类型参数。支持子类直接继承泛型、固定某些泛型类型
:
class B extends A<string, any> {}
// 使子类直接继承泛型、固定某些泛型类型
class C<T, U> extends A<T, U> {}
class D<T, U> extends A<string, U> {}
泛型还可以引用于类的表达式(字面量、运算、函数):
class E<T> {
constructor(private readonly data: T) {}
value: T
add: (x: T) => T
}
const ce = new E<number>(123)
Js 的类本质上是一个构造函数,因此泛型类也可以写为构造函数:
type MyClass<T> = new (...args: any[]) => T
// 对象形式
interface MyClass2<T> {
new (...args: any[]): T
}
function createInstance<T>(AnyClass: MyClass<T>, ...args: any[]): T {
return new AnyClass(...args)
}
上面示例中,参数 AnyClass 是一个构造函数或类,可以在调用 createInstance 时再指定参数类型。
注意,静态类型、静态方法不能引用类型参数,因为它们是静态
的,只作用于类的本身:
class C<T> {
static data: T // 报错
constructor(public value: T) {}
}
类型别名的泛型写法
type 命令定义的类型别名,也可以使用泛型:
type Tn<T> = T | number
const tn: Tn<string> = '123'
用于定义对象时,还可以递归引用泛型:
type obj<T> = {
value: T
deepObj: obj<T> | null
}
const deepObj: obj<number> = {
value: 123,
deepObj: {
value: 123,
deepObj: null,
},
}
类型参数的默认值
类型参数支持设置默认值
,如果使用时没给出参数类型,则使用默认值:
function atTwo<T = string>(arr: T[]): T {
return arr[1]
}
在设置了类型参数默认值的场景,如调用时不声明参数类型,Ts 会自动推断类型,此时类型推断会覆盖类型参数的默认值:
atTwo([1, 2, 3]) // function atTwo<number>(arr: number[]): number
上面示例中,未声明参数类型,传入了不符合默认值的类型,但也不会导致报错,这是因为 Ts 从实际参数自动推断的类型number覆盖了默认值的string类型
。
类型参数的默认值,也常用于类中:
class GClass<T = string> {
list: T[] = []
add(t: T) {
this.list.push(t)
}
}
const g = new GClass<number>()
const g1 = new GClass()
g.add(1)
g1.add('1')
类型参数具备默认值,则表示该参数是可选参数,如存在多个类型参数,必循可选参数在必选参数之后
的原则:
type use<T = number, U> = T | U // 报错
type use<U, T = number> = T | U
数组的泛型表示
在 Ts 中,数组可以用Array<T>
表示,Array
是 Ts 提供的一个原生接口,T
则是该接口的类型参数:
let arr: Array<number> = [1, 2, 3]
事实上,在 Ts 中,数组写法number[]
,是Array<number>
的简写形式,在 Ts 内部,Array 是一个泛型接口,类型定义基本如下:
interface Array<Type> {
length: number
pop(): Type | undefined
push(...items: Type[]): number
// ...
}
上面示例中,Array 方法设计的参数类型需要跟类型参数保持一致,只能添加同类型成员。
Ts 还提供了ReadonlyArray<T>
接口,表示只读数组
:
function addVal<T>(values: ReadonlyArray<T>) {
values.push(1) // 报错,只读数组不允许修改
}
类型参数的约束条件
类型参数允许传入约束条件
,往往用于传入类型可能不满足函数条件的情况:
function comp<T>(a: T, b: T) {
if (a.length > b.length) return a
return b
}
上面示例中,函数存在约束条件,如传入的类型参数不具备length
属性,就会报错。
Ts 提供了在类型参数上添加约束条件的语法
,具备良好的语义说明:
function comp2<T extends { length: number }>(a: T, b: T) {
if (a.length > b.length) return a
return b
}
comp2([1, 2], [3, 4])
comp2('12', '3')
comp2(1, 2) // 报错
上面示例中,T extends { length: number }
代表传入的类型参数 T 必须具备length
属性,否则就会报错。
<TypeParameter extends ConstraintType>
上面语法中,TypeParameter 为 ConstraintType 的子类型
,ConstraintType 表示类型参数要满足的条件。
类型参数与类型参数的默认值可以同时设置
,前提是需要满足约束条件:
type AB<A extends string, B extends string = 'world'> = [A, B]
type Res = AB<string> // type Res = [string, "world"]
上面例子中,可以看出泛型本质上是一个类型函数,通过输入类型参数,建立类型结果之间的对应关系。
如果存在多个类型参数,一个类型参数的约束条件可以是引用其他类型参数
:
<T, U extends T>
// 或者
<T extends U, U>
注意,约束条件不能引用类型参数自身
,且多个类型参数之间不能互相约束(循环约束)
:
<T extends T> // 报错
<T extends U, U extends T> // 报错
使用注意
尽量少地使用泛型。
泛型固然灵活,但会增加代码的
复杂性
,降低可读性。类型参数越少越好。
多一个类型参数,就要多一次替换步骤,
类型参数越少越好
,类型参数的可复用性越高越好。function filter<T, Fn extends (arg: T) => boolean>(arr: T[], fn: Fn) { return arr.filter(fn) } // 简化 function filter<T>(arr: T[], fn: (arg: T) => boolean) { return arr.filter(fn) }
上面示例中,函数的第二个类型参数
Fn
是非必要的,也无法提供有效的复用场景,完全可以直接声明在函数形参后。类型参数需要出现两次。
如果类型参数定义后只需要使用一次,
没有两次或两次以上的复用性
,那该泛型的定义完全没有必要。泛型可以嵌套。
一个泛型的类型参数可以是另一个泛型:
type X<T> = T | null type Y<T> = T | T[] type Z<T> = Y<X<T>> const type: Z<number> = [123] const type2: Z<number> = 123 const type3: Z<number> = null
Enum 类型
Enum(枚举)
是 Ts 的一种数据结构和类型。
在实际开发中,我们往往需要定义一组相关常量,用于语义化地映射状态
做处理:
const RED = 1
const GREEN = 2
let color = userInput()
if (color === RED) {
/* */
}
if (color === GREEN) {
/* */
}
Ts 就为此设计了Enum
结构,用于将相关常量定义在一起,方便使用:
enum Color {
Red, // 0
Green, // 1
}
let green = Color.Green // 1
let green2 = Color['Green'] // 1
上面示例中,定义了一个 Enum 结构 Color,成员的第一个值默认为整数 0,第二个则为 1,以此类推
。
使用时,与调用对象属性的语法一致,可以使用.
运算符,也可以使用[]
运算符。
变量 green,它的值为整数 1,所以,它的类型可以是enum Color
,也可以是number
,甚至可以是Color.Green
:
let green3: Color = Color.Green
let green4: number = Color.Green
let green5: Color.Green = Color.Green
Enum 结构的特别之处在于,它既是类型,又是一个值
。不同于其他 Ts 语法只是类型语法,在编译后会全部删除,Enum 结构在编译后会变成包含相关枚举值属性的 Js 对象,保留在代码当中
,属于 Ts 的一种不可擦除语法:
// 编译前
enum Color {
Red, // 0
Green, // 1
}
// 编译后
let Color = {
Red: 0,
Green: 1,
}
注意,由于 Enum 结构编译为 Js 后是一个对象,所以不能有与它重名的任何定义(包括函数、对象、类等),只能与命名空间或其他枚举声明合并:
enum Color {
Red,
Green,
}
const Color = 'red' // 报错
由于 Ts 的定位是 Js 的类型
增强,所以对于 Enum 结构这种既是类型又是值(编译后会增加一个对象)的增强,官方建议谨慎使用。
Enum 结构适用的场景是,成员值不重要,语义化的成员名更重要
,这有利于增加代码的语义化可读性和可维护性:
function compute(op: Compute, a: number, b: number) {
switch (op) {
case Compute.Add:
return a + b
case Compute.Sub:
return a - b
default:
throw new Error('wrong')
}
}
compute(Compute.Add, 1, 2) // 3
上面示例中,Enum 结构成员代表加减运算,可以语义化的传入 Enum 成员名,代表加法运算。
大多数情况下,Enum 结构可以被as const
声明的原生 Js 对象替代:
enum EBar {
A,
B,
}
// 属性及变量都只读
const Bar = {
A: 0,
B: 1,
} as const
EBar.A // 0
Bar.A // 0
在 Ts5.0 之前,Enum 还存在一个 bug,Enum 类型的变量可以赋值为任何数值,不受 Enum 结构限制:
enum Bool {
No,
Yes,
}
function foo(noYes: Bool) {
// ...
}
foo(33) // Ts 5.0 之前不报错,只能赋值为0、1才对
Enum 成员的值
Enum 成员默认不需要赋值,默认从0开始递增,以此类推
。
Enum 成员也支持显式赋值:
enum Box {
A = 0,
B = 1,
}
成员的值可以是任意数值
,但不能是大整数Bigint
:
enum Box {
C = 0.5,
D = 90,
E = 7n, // 报错
}
成员的值甚至可以相同:
enum Zero {
A = 0,
B = 0,
C = 0,
}
如果只显式赋值了第一个成员的值,那么后面成员的值会从这个值开始递增(每次递增 1):
enum A {
A = 1,
B, // 2
C, // 3
}
enum B {
A,
B = 3,
C, // 4
}
成员值还可以是计算式:
let status = 3
enum ABCD {
A = num(),
B = 1 << 2,
C = Math.random(),
D = 1 + 2,
E = status, // 表达式
}
上面示例中,Enum 的成员的值等于一个计算式,或可以等于函数返回值。
Enum 的成员值都是只读的,不能重新赋值:
enum Color {
Red,
Green,
}
Color.Red = 4 // 报错
为了让 Enum 成员不能重新赋值这点更加醒目,通常会在 enum 关键字前加上const
修饰符,表示这是常量,不能再次赋值:
const enum AB {
A = 1,
B = 2,
}
上面示例中,加上了 const 的 enum 还有一个好处
,在编译为 Js 代码后,代码中的Enum成员会被替换为对应的内联枚举值(常量),而不是生成一个运行时的枚举对象
,提高了性能表现:
// ...上面示例
const value = 1
// 编译前
if (value === AB.A) {
/* */
}
// 编译后
if (value === 1 /* AB.A */) {
/* */
}
注意,因为const enum
编译后产物为内联值,不生成运行时对象,所以存在以下限制:
const enum
成员不能有计算成员,成员值必须是常量(字面量)值(如字符串、数字或常量计算值),不能是变量或函数返回值:const b = 2 let c = 3 const enum ABC { A = 1, B = b, // 正确 C = c, // 报错 D = Math.random(), // 报错 }
不能使用反向映射(因为不生成运行时对象)。
如果希望加上 const 修饰符后,运行时还能访问 Enum 结构(编译后仍生成运行时对象),可以在编译时打开preserveConstEnums
配置项。
同名 Enum 的合并
多个同名
的 Enum 结构会自动合并
:
enum Foo {
A,
}
enum Foo {
B = 1,
}
enum Foo {
C = 2,
}
// 等同于
enum Foo {
A,
B = 1,
C = 2,
}
注意,同名 Enum 结构合并时,存在以下限制,否则会报错:
只允许其中一个的首位成员
省略初始值
:enum Foo { A, } enum Foo { B, // 报错 B = 1, // 正确 }
不能有
同名成员
:enum Foo { A, } enum Foo { A = 1, // 报错 }
所有定义必须
同为enum或const enum
,不允许混合合并:// 正确 enum E { A, } enum E { B = 1, } // 正确 const enum E { A, } const enum E { B = 1, } // 报错 enum E { A, } const enum E { B = 1, }
同名 Enum 的合并,最大用处就是补充外部定义
的 Enum 结构。
字符串 Enum
Enum 的成员除了设为数值,还可以设为字符串
,即,Enum 也可以用作一组相关字符串的集合:
enum Direction {
Up = 'Up',
Down = 'Down ',
}
注意,字符串 Enum 所有成员值,必须显式声明
,未声明,则成员默认为数值
,且未声明的数值成员位置必须在字符串成员之前(数值成员才可以从 0 默认递增):
enum Foo {
A, // 0
B = 'hello',
C, // 报错
}
声明位置在中间的字符串成员会影响往后未声明成员的默认值导致报错,重新赋值初始值,数值成员即可正确递增(为了减少心智负担,具有默认成员的 Enum 结构,字符成员一般赋值在最后
):
enum Foo {
A,
Up = 'Up',
B, // 包括C也报错
// B = 1, // 正确,C为2
C,
}
Enum 成员可以是字符串合数值的混合:
enum Enum {
One = 'One',
Two = 'Two',
Three = 3,
Four = 4,
}
Enum 成员只允许数值和字符串,不允许其他类型
。
变量类型如果是字符串 Enum,就不能再赋值为字符串,这与数值 Enum 的行为不一样:
// 字符串
enum MyStrEnum {
A = 'Aa',
B = 'Bb',
}
let strEnum = MyStrEnum.A
strEnum = 'C' // 报错
// 数值
enum MyEnum {
A,
B,
}
MyEnum.A // 0
MyEnum[0] // A
let ea = MyEnum.A
ea = 1 // 1 可重新赋值
ea = 2 // 报错
上面示例中,变量类型是字符串 Enum 的变量无法重新赋值,而数值 Enum 可以,原因是字符串数值是不支持反向映射的
。
也可以理解为它是一个类型收窄
(类型缩小)的情况,Ts 收窄了可接受的值的范围,通过声明变量类型为联合类型MyStrEnum | string
类型即可赋值。
上面示例中,数值Enum类型的变量可重新赋值的范围也是有限的,仅允许赋值枚举成员范围的数值
,而不是任意数值(同样的,MyEnum | number
即可赋值)。
前文提过,数值 Enum 的成员值
不重要(语义化的成员名更重要)。但有些场景,开发者希望 Enum 成员值可以保存有效信息,所以 Ts 才设计了字符串 Enum:
const enum MediaTypes {
JSON = 'application/json',
XML = 'application/xml',
}
fetch('localhost', {
headers: {
Accept: MediaTypes.JSON,
},
}).then((response) => {
// ...
})
上面示例中,Accept 字段就很适合把字符串放进一个 Enum 结构,通过语义化来引用字符成员值。
字符串 Enum 可以使用字符值与联合类型代替
,效果与使用 Enum 一致:
let direction: 'Up' | 'Down' = 'Up'
注意,字符串 Enum 的成员值,不能使用表达式赋值(一般也不会有人这么个哈哈):
enum MyEnum {
A = 'one',
B = ['T', 'w', 'o'].join(''), // 报错
}
keyof 运算符
keyof
运算符可以取出 Enum 结构的所有成员名,作为联合类型返回:
enum KeyE {
A = 'a',
B = 'b',
}
let key: keyof typeof KeyE = 'A' // 'A' | 'B'
上面示例中,keyof typeof KeyE
可以取出KeyE
的所有成员名,返回联合类型'A' | 'B'
,注意,这里的typeof
是必须的,否则keyof KeyE
相当于keyof string
,返回的是一个等于包含string类型所有原生属性名组成的联合类型
,如果是数值 Enum,则相当于返回keyof number
:
type Foo = keyof MyEnum
// number | typeof Symbol.iterator | "toString" | "charAt" | "charCodeAt" | ...
这是因为 Enum 作为类型,本质上相当于 number 或 string 类型的一种变体,而 typeof 先将 Enum 作为一个值处理,将其转为了对象
(enum 编译后生成的枚举对象),从而通过keyof
运算符返回该对象的所有属性名
。
如果需要返回 Enum 的所有成员值,可以使用in
运算符:
enum AllValue {
A = 1,
B = 3,
C = 5,
}
type allValue = { [key in AllValue]: any }
反向映射
数值 Enum 存在反向映射
,即可以通过成员的值获得成员名:
enum Map {
A = 1,
B,
}
Map[2] // B
Map.B // 2 正向映射,从成员名映射到值
上面示例中,约等于 Ts 对对象枚举编译出了映射代码:
const all = {
w: 1,
}
all[(all['w'] = 1) /* 赋值并返回1 */] = 'w'
// 简化
all['w'] = 1
all[1] = 'w'
字符串 Enum 不存在反向映射,因为字符串 Enum 编译后每个成员只有一组赋值:
enum MyEnum {
A = 'a',
B = 'b',
}
MyEnum['A'] = 'a'
MyEnum['B'] = 'b'
const enum
也不支持反向映射,因为根本不生成运行时对象。
类型断言
对于没有声明类型的值,Ts 会做自动类型推断,但有时候推断结果未必是开发者想要的:
type T = 'a'
let foo = 'a'
let bar: T = foo // 报错
上面示例中,报错原因是 Ts 推断 foo 类型为 string,导致赋值为值类型时报错,对于这种情况,可使用类型断言
解决,解决类型推断的冲突:
let bar2: T = foo as T // 告诉Ts foo是T类型
Ts 提供的类型断言
,允许开发者在代码中断言
某个值
的类型,告诉 Ts 编译器此处的值是什么类型,Ts 一旦发现存在类型断言,即中止类型推断,并采用断言给出的类型
。
断言的本质是,允许开发者在某处代码绕过编译器的类型推断,避免编译器报错,让开发者自己决定如何处理,虽然有些情况会降低 Ts 的严格性,但总体提高了灵活度。
类型断言有两种语法:
<T>value // <类型>值
value as T // 值 as 类型
语法二是 Ts 为了解决在 React 的 JSX 语法(<>表示 HTML 元素)中的冲突而引入的,也更加语义化,一般不推荐使用语法一。
来看一个对象的例子,前文提到,对象类型存在严格字面量检查
,会导致赋值字面量对象存在额外属性时报错:
const obj: { x: number } = { x: 0, y: 0 }
解决方法是使用类型断言,可以用两种断言类解决:
const obj2: { x: number } = { x: 0, y: 0 } as { x: number }
const obj3: { x: number } = { x: 0, y: 0 } as { x: number; y: number }
上面示例中,第一种断言是将类型断言与声明的断言一致。而第二种则是利用类型兼容原则,具备更多属性的对象是子类型,反之则是父类型,父类型可以赋值为子类型
。同时因为存在类型断言,就不会做严格字面量检查了,报错得以解决。
再来看一个 Dom 编程的例子:
const username = document.querySelector('#username')
if (username) (username as HTMLInputElement).value
上面示例中,username 被推断为Element
类型,所以会报错没有value
属性,所以这里将 username 属性加上()
,将类型断言为HTMLInputElement
,报错得以解决。
类型断言不应滥用,因为它中断了 Ts 的类型检查:
const obj: object = { a: 1 }
obj.length // 报错
;(obj as Array<any>).length
;(obj as any).length
上面示例中,不存在的属性调用报错,但通过断言使代码通过了检查。这是错误的留隐患的用法,代码编译后依然会报错。
类型断言还可以用于断言unknown
类型变量的具体类型,取消赋值限制,使之可以正常赋值给其他变量:
const val: unknown = 'nuknown'
let str: string = val
let str2: string = val as string
类型断言的条件
类型断言并不意味着,可以随意把某个值断言为其他类型:
const n = 1
const m: string = n as string // 报错
上面示例中,值类型变量 n 并无法断言为字符串。
类型断言存在使用前提,值的实际类型与断言类型必须满足一个条件:
value as T
即值的实际类型与断言的类型需要兼容
,value 可以是 T 的子类型,或 T 是 value 的子类型,可以把实际类型断言为宽泛的父类型
,也可以断言为精确的子类型
,但不能是完全无关的类型(让 Ts 觉得一眼假哈哈哈)。
如果真的想要断言为一个完全无关的类型,可以利用any unknown
作为 Ts顶层类型
(其他所有类型的父类型)的特性,连续两次断言
,最后断言为目标类型:
const n = 1
const m1: string = n as unknown as string
const m2: string = n as any as string
const m3: string = n as any // 直接断言any会降低代码可读性,使此处实际类型模糊
const m4: string = n as never as string // 底层类型never也可以,因为它是所有类型的子类型
as const 断言
如果没有声明变量类型,let
命令声明的变量类型推断为 Ts 对应基本类型
,而const
则推断为值类型
常量:
let s1 = 'js'
const s2 = 'js'
上面示例中,值类型算是前者的子类型,而 let 命令声明变量时 Ts 做的类型推断往往是字面量的基本类型,偶尔会导致一些意想不到的报错:
let l = 'js'
type Lang = 'js' | 'go' | 'c++'
function selectLang(lang: Lang) {}
selectLang(l) // 报错,string类型不能入参
上面示例中,let 变量被推断为string
类型,是 Lang 这个联合类型的父类型,父类型不能赋值为子类型
,导致报错。
除了把变量从 let 命令声明改为 const 命令声明之外,还有一种解决方法就是使用类型断言,把 let 变量断言为 const 常量,从而把推断的基本类型改为值类型:
const l = 'js' // 改用const声明
let l = 'js' as const
使用 as const 断言后,let变量也不允许再重新赋值了
。
let l = 'js' as const
l = 'js2' // 报错
注意,as const断言只能用于字面量,而不是变量
:
let l = 'js'
selectLang(l as const) // 报错
let l2 = l as const // 报错
as const 也不能用于表达式
:
let l2 = ('js' + 'script') as const // 报错
as const 还支持前置形式的语法:
let l = <const>'js' // 不是那么语义化
as const 可以断言对象或对象属性,它们的类型缩窄效果是不一样的:
const v1 = { x: 1 } // { x: number } x基本类型number
const v2 = { x: 1 as const } // { x: 1 } x值类型
const v3 = { x: 1 } as const // { readonly x: 1 } 对象只读
总而言之,as const 会将字面量的类型断言为不可变的、Ts允许的最小类型
:
const a = [1, 2, 3] // number[]
const a1 = [1, 2, 3] as const // readonly [1,2,3] ,只读元组
由于 as const 会将数组变为只读元组,所以特别适用于函数的rest参数
:
function rest(a, b) {}
// const arr = [1, 2] // 报错,因为Ts不确定arr的元素个数
const arr = [1, 2] as const // 只读元组,入参保证没问题
rest(...arr)
Enum 成员也可以适用 as const 断言,使变量类型推断为 Enum 的成员而不是整个 Enum 类型:
enum Foo {
x,
y,
}
let e = Foo.x // Foo
const e2 = Foo.x as const // Foo.x,当然,直接用const声明也是一个效果
非空断言
注意,非空断言只有在打开编译选项strictNullChecks
时才有意义,否则编译器就不会检查某个变量是否可能为 undefined 或 null。
对于可能为空的变量(undefined
和null
),Ts 提供了非空断言,用于告诉 Ts,这些变量不为空,语法是在变量后加上!
:
function f(x?: number) {
validateNumber(x)
console.log(x!.toFixed())
}
function validateNumber(e?: number | null) {
if (typeof e !== 'number') throw new Error('Not a number')
}
上面示例中,参数 x 可能不存在,但开发者已经通过前置函数校验,保证变量不为空,这时就可以加上!
,使x!.toFixed()
不再报错。
非空断言还可以用于 Dom 编程,告诉 Ts 某个元素一定存在:
const root = document.querySelector('.root')!
// 不过,为了应对意外情况,比较保险的写法是手动检查元素是否为空
if (root) {
}
非空断言还可以用于赋值断言,Ts 有一个编译配置会要求类的属性必须初始化(strictPropertyInitialization,需要和 strictNullChecks 同时启用),如为空值则报错,此时就可以使用非空断言,表示属性一定会有值:
class XY {
// x: number // 报错
// y: number // 报错
x!: number
y!: number
}
Ts 非空断言和 JsEs9 语法?.
可选链的区别
一个是 Ts 编译时语法,一个是 Js 运行时语法,共同点都是用于解决可能为undefined
和null
的问题。
断言函数
断言函数是一种特殊函数,在参数不符合类型时抛出错误
,中断程序执行,用于保证函数参数符合某种类型(类型收窄)
:
function isString(value: unknown): void {
if (typeof value !== 'string') throw new Error('not a striing')
}
function toUpper(x: string | number) {
isString(x)
return x.toUpperCase() // x类型是string | number,会警告类型number上不存在toUpperCase
}
上面示例中,x 参数可能是 number 类型,而 number 类型不存在 toUpperCase 方法,必须保证 x 的类型为 string,所以使用断言函数 isString 在类型不符合的情况下抛出异常中断函数。
但是传统的断言函数,参数类型是 nuknown,返回值是 void,很难体现它是一个断言函数,而且Ts上下文不一定关联断言函数判断入参类型
,会导致上面示例中的警告。
为了更语义清晰地表达断言函数,Ts3.7 引入了新的类型写法:
// function 函数名(参数): asserts 参数 is 类型
function isString(value: unknown): asserts value is string {
if (typeof value !== 'string') throw new Error('not a striing')
}
function toUpper(x: string | number) {
isString(x)
return x.toUpperCase() // x类型是string
}
上面示例中,断言函数的返回值写为asserts value is string
,asserts
、is
为关键字,value 是函数的参数名,断言 value 类型为 string。
使用了断言函数新的写法后,Ts会识别关联上下文,对应的入参变量也会识别为断言的类型
。
另外,断言函数的asserts
语句等于void
类型,函数返回值只能是undefined
、null
,否则报错:
function isString(value: unknown): asserts value is string {
if (typeof value !== 'string') throw new Error('not a striing')
return true // 不能将类型boolean分配给类型void
}
注意,函数返回值的断言写法,只用于清晰表达函数意图,函数内部检查如果与断言不一致,Ts 并不能识别报错:
function isString(value: unknown): asserts value is string {
if (typeof value !== 'number') throw new Error('not a striing')
}
如果要断言参数非空,可以使用工具类型NoNullable<T>
,可以理解它是一个类型过滤器
,会去除传入的类型参数中可能包含的空类型,返回剩余类型:
interface User {
name: string | null
}
function getUserName(user: NonNullable<User['name']>) {
return `用户名:${user}` // user类型string
}
如果要将断言函数用于函数表达式
,可以采用下面的写法。根据 Ts 的要求,这时函数表达式所赋予的变量,必须有明确的类型声明:
type AssertIsNumber = (value: unknown) => asserts value is number
const assertIsNumber: AssertIsNumber = (value) => {
if (typeof value !== 'number') throw Error('Not a number')
}
注意,断言函数并不等同于
类型保护(确认、检查)函数,使用场景也不同,一个用于断言并中断(抛出异常),一个用于确认、检查并使用(提供返回值):
function isNumber(value: unknown): value is number {
return typeof value === 'number'
}
上面示例中,is
是一个类型运算符,如果左侧值符合右侧,则返回 true,反之则 false。
如果要断言某个参数保证为真
(即不为 false、undefined、null),Ts 还提供了一种断言函数的简写形式
:
// asserts省略了谓宾,表示v保证为真提供
function assert(v: unknown): asserts v {
if (!v) throw new Error(`${v}`)
}
这类简写的断言函数,一般用于检查某个操作(如从接口获取)是否成功:
let age = null
// ...age赋值逻辑
function assert(v: unknown, errText: string): asserts v {
if (!v) throw new Error(`${errText}`)
}
console.log(assert(age, 'age获取错误'))
断言函数,本质是一种 Ts 用于类型收窄的语义化语法,可以在某些场景中替代as
,实现更清晰的函数表达:
function toUpper3(x: string | number) {
isString3(x)
return (x as string).toUpperCase()
}
Modlue 模块
在 Js 中,任何包含 import、export 语句的文件,都可以被视为模块(module)。
Ts 在支持所有 Es 模块语法的同时,还允许输入及输出类型模块:
// 暴露
export interface User {
name: string
}
// or
// ...
export { User }
// 引用
import type { Bool } from './modules'
import type
默认情况下,开发者无法区分导入的是普通模块还是类型模块,可能会导致类型作为值使用导致报错,Ts 带来了两种语法用于解决,表示导入模块为类型模块:
type关键字
import { type User } from './modules'
import type语句
import type { User } from './modules'
Ts5.0 新增了verbatimModuleSyntax
配置项,作用是对模块语法做保留(不会添加、移除、转换模块语法),但不会影响类型语法擦除的行为,它同时带来了语法限制,需要严格区分类型导入与普通导入
,类型导入必须显式用type
进行标记,开启后的强制性能更清晰地区分类型导入。
import type 语句也可以输入默认导出模块:
import type User from './modules'
对于别名也适用:
import type * as User from './modules'
export 也有语句和关键字的语法,表示导出模块为类型:
type A = string
type B = number
export type { A }
export { type B }
路径映射
Ts 允许开发者手动配置模块引入的映射路径:
{
"compilerOptions": {
"baseUrl": ".", // 基准路径
"paths": {
// 配置非相对路径外部模块的引入映射
"jquery": ["node_modules/jquery/dist/jquery"] // 值为数组,可传入多个路径,如第一个路径无效则加载第二个路径,以此类推
}
}
}
@types
node_modules/@types
通常用于存放安装的第三方库的类型声明文件,使 Ts 能识别与校验第三方库类型:
npm install @types/lodash --save-dev
tsconfig.json 中提供了typeRoots
配置项,可用于定义类型声明文件的目录位置:
{
"typeRoots": [
"./node_modules/@types",
"./src/@types" // 自定义类型声明文件
]
}
namespace
在 Es 模块诞生前,Ts 推出过自己的模块格式 namespace,现如今已不推荐使用。