类型系统
提示
本章是 TypeScript 类型系统的总体介绍。
TypeScript 继承了 JavaScript 的类型,在这个基础上,定义了一套自己的类型系统。
基本类型
概述
JavaScript 语言(注意,不是 TypeScript)将值分成 8 种类型:
- boolean
- string
- number
- bigint
- symbol
- object
- undefined
- null
TypeScript 继承了 JavaScript 的类型设计,以上 8 种类型可以看作 TypeScript 的基本类型。
注意
上面所有类型的名称都是小写字母,首字母大写的 Number、String、Boolean 等在 JavaScript 语言种都是内置对象,而不是类型名称。
另外,undefined 和 null 即可以作为值,也可以作为类型,取决于在哪里使用它们。
这 8 种基本类型是 TypeScript 类型系统的基础,复杂类型由它们组合而成。
boolean 类型
boolean 类型只包含 true 和 false 两个布尔值。
const x: boolean = true
const y: boolean = falsestring 类型
string 类型包含所有字符串。
const x: string = 'hello'
const y: string = `${x} world`number 类型
number 类型包含所有整数和浮点数。
const x: number = 123
const y: number = 3.14
const z: number = 0xffffbigint 类型
bigint 类型包含所有的大整数。
const x: bigint = 123n
const y: bigint = 0xffffn注意
bigint 与 number 类型不兼容。
const x: bigint = 123 // 报错
const y: bigint = 3.14 // 报错另外,bigint 类型是 ES2020 标准引入的。如果使用这个类型,TypeScript 编译的目标 JavaScript 版本不能低于 ES2020(即编译参数 target 不低于 es2020)。
symbol 类型
symbol 类型包含所有的 Symbol 值。
const x: symbol = Symbol()symbol 类型的详细介绍,参见《Symbol》一章。
object 类型
根据 JavaScript 的设计,object 类型包含了所有对象、数组和函数。
const x: object = { foo: 123 }
const y: object = [1, 2, 3]
const z: object = (n: number) => n + 1undefined 类型,null 类型
undefined 和 null 是两种独立类型,它们各自都只有一个值。
undefined 类型只包含一个值 undefined,表示未定义(即还未给出定义,以后可能会有定义)。
let x: undefined = undefined上面示例中,变量 x 就属于 undefined 类型。两个 undefined 里面,第一个是类型,第二个是值。
null 类型也只包含一个值 null,表示为空(即此处没有值)。
const x: null = null注意
如果没有声明类型的变量,被赋值为 undefined 或 null,在关闭编译设置 noImplicitAny 和 strictNullChecks 时,它们的类型会被推断为 any。
// 关闭 noImplicitAny 和 strictNullChecks
let a = undefined // any
const b = undefined // any
let c = null // any
const d = null // any如果希望避免这种情况,则需要打开编译选项 strictNullChecks。
// 打开 strictNullChecks
let a = undefined // undefined
const b = undefined // undefined
let c = null // null
const d = null // null包装对象类型
包装类型的概念
JavaScript 的 8 种类型之中,undefined 和 null 其实是两个特殊值,object 是复合类型,剩下的五种属于原始类型(primitive value),代表最基本的、不可再分的值。
- boolean
- string
- number
- bigint
- symbol
上面这五种原始类型的值,都有对应的包装对象(wrapper object)。所谓“包装对象”,指的是这些值在需要时,会自动产生的对象。
'hello'.charAt(1) // 'e'上面示例中,字符串 hello 执行了 charAt() 方法。但是,在 JavaScript 语言中,只有对象才有方法,原始类型的值本身没有方法。这行代码之所以可以运行,就是因为在调用方法时,字符串会自动转为包装对象,charAt() 方法其实是定义在包装对象上。
这样的设计大大方便了字符串处理,省去了将原始类型的值手动转成对象实例的麻烦。
五种包装对象之中,symbol 类型和 bigint 类型无法直接获取它们的包装对象(即 Symbol() 和 BigInt() 不能作为构造函数使用),但是剩下三种可以。
Boolean()String()Number()
以上三个构造函数,执行后可以直接获取某个原始类型值的包装对象。
const s = new String('hello')
typeof s // 'object'
s.charAt(1) // 'e'注意
String() 只有当作构造函数使用时(即带有 new 命令调用),才会返回包装对象。如果当作普通函数使用(不带有 new 命令),返回就是一个普通字符串。其他两个构造函数 Number() 和 Boolean() 也是如此。
包装类型与字面量类型
由于包装类型的存在,导致每一个原始类型的值都有包装对象和字面量两种情况:
'hello' // 字面量
new String('hello') // 包装对象为了区分这两种情况,TypeScript 对五种原始类型分别提供了大写和小写两种类型:
- Boolean 和 boolean
- String 和 string
- Number 和 number
- Bigint 和 bigint
- Symbol 和 symbol
其中,大写类型同时包含包装对象和字面量两种情况,小写类型只包含字面量,不包含包装对象。
const s1: String = 'hello' // 正确
const s2: String = new String('hello') // 正确
const s3: string = 'hello' // 正确
const s4: string = new String('hello') // 报错建议
只使用小写类型,不使用大写类型。因为绝大部分使用原始类型的场合,都是使用字面量,不使用包装对象。而且,TypeScript 把很多内置方法的参数,定义成小写类型,使用大写类型会报错。
const n1: number = 1
const n2: Number = 1
Math.abs(n1) // 1
Math.abs(n2) // 报错上一小节说过,Symbol() 和 BigInt() 这两个函数不能当作构造函数使用,所以没有办法直接获得 symbol 类型和 bigint 类型的包装对象,除非使用下面的写法:
let a = Object(Symbol())
let b = Object(BigInt())但是,它们没有使用场景,因此 Symbol 和 BigInt 这两个类型虽然存在,但是完全没有使用的理由。
注意
目前在 TypeScript 里面,symbol 和 Symbol 两种写法没有差异,bigint 和 BigInt 也是如此,不知道是否属于官方的疏忽。建议始终使用小写,不要使用大写。
Object 类型与 object 类型
Object 类型
大写的 Object 类型代表 JavaScript 语言里面的广义对象。所有可以转成对象的值,都是 Object 类型,这囊括了几乎所有的值。
let obj: Object
obj = true
obj = 'hi'
obj = 1
obj = { foo: 123 }
obj = [1, 2]
obj = (a: number) => a + 1事实上,除了 undefined 和 null 这两个值不能转为对象,其他任何只都可以赋值给 Object 类型。
let obj: Object
obj = undefined // 报错
obj = null // 报错另外,空对象 {} 是 Object 类型的简写形式:
let obj: {}
obj = true
obj = 'hi'
obj = 1
obj = { foo: 123 }
obj = [1, 2]
obj = (a: number) => a + 1显然,无所不包的 Object 类型即不符合直觉,也不方便使用。
object 类型
小写的 object 类型代表 JavaScript 里面的狭义对象,既可以用字面量表示的对象,只包含对象、数组和函数,不包括原始类型的值。
let obj: object
obj = { foo: 123 }
obj = [1, 2]
obj = (a: number) => a + 1
obj = true // 报错
obj = 'hi' // 报错
obj = 1 // 报错大多数时候,我们使用对象类型,只希望包含真正的对象,不希望包含原始类型。所以,建议总是使用小写类型 object,不使用大写类型 Object。
注意
无论是大写的 Object 类型,还是小写的 object 类型,都只包含 JavaScript 内置对象原生的属性和方法,用户自定义的属性和方法不存在于这两个类型之中。
const o1: Object = { foo: 0 }
const o2: object = { foo: 0 }
o1.toString() // 正确
o1.foo // 报错
o2.toString() // 正确
o2.foo // 报错上面示例中,toString() 是对象的原生方法,可以正确访问。foo 是自定义属性,访问就会报错。如何描述对象的自定义属性,详见《对象类型》一章。
undefined 和 null 的特殊性
undefined 和 null 既是值,又是类型。
作为值,它们有一个特殊的地方:任何其他类型的变量都可以赋值为 undefined 和 null。
let age: number = 24
age = null // 正确
age = undefined // 正确这并不是因为 undefined 和 null 包含在 number 类型里面,而是故意这样设计,任何类型的变量都可以赋值为 undefined 和 null,以便跟 JavaScript 的行为保持一致。
JavaScript 的行为是,变量如果等于 undefined 就表示还没有赋值,如果等于 null 就表示值为空。所以,TypeScript 就允许了任何类型的变量都可以赋值为这两个值。
但是有时候,这并不是开发者想要的行为,也不利于发挥类型系统的优势。
const obj: object = undefined
obj.toString() // 编译不报错,运行就报错上面示例中,变量 obj 等于 undefined,编译不会报错。但是,实际执行时,调用 obj.toString() 就报错了,因为 undefined 不是对象,没有这个方法。
为了避免这种情况,及早发现错误,TypeScript 提供了一个编译选项 strictNullChecks。只要打开这个选项,undefined 和 null 就不能赋值给其他类型的变量(除了 any 和 unknown 类型)。
下面是 tsc 命令打开这个编译选项的例子:
// tsc --strictNullChecks app.ts
let age: number = 24
age = null // 报错
age = undefined // 报错这个选项在配置文件 tsconfig.json 的写法如下:
{
"compilerOptions": {
"strictNullChecks": true
// ...
}
}打开 strictNullChecks 以后,undefined 和 null 这两种值也不能相互赋值了:
// 打开 strictNullChecks
let x: undefined = null // 报错
let y: null = undefined // 报错总之,打开 strictNullChecks 以后,undefined 和 null 只能赋值给自身,或者 any 和 undefined 类型的变量:
let x: any = undefined
let y: unknown = null值类型
TypeScript 规定,单个值也是一种类型,称为“值类型”。
let x: 'hello'
x = 'hello' // 正确
x = 'world' // 报错上面示例中,变量 x 的类型是字符串 hello,导致它只能赋值为这个字符串,赋值为其他字符串就会报错。
TypeScript 推断类型时,遇到 const 命令声明的变量,如果代码里面没有注明类型,就会推断该变量是值类型。
// x 的类型是 "https"
const x = 'https'
// y 的类型是 string
const y: string = 'https'这样推断是合理的,因为 const 命令声明的变量,一旦声明就不能改变,相当于常量。值类型就意味着不能赋为其他值。
注意
const 命令声明的变量,如果赋值为对象,并不会推断为值类型。
// x 的类型是 { foo: number }
const x = { foo: 1 }这是因为 JavaScript 里面,const 变量赋值为对象时,属性值是可以改变的。
值类型可能会出现一些很奇怪的报错。
const x: 5 = 4 + 1 // 报错上面示例中,等号左侧的类型是数值 5,等号右侧 4 + 1 的类型,TypeScript 推测为 number。由于 5 是 number 的子类型,number 是 5 的父类型,父类型不能赋值给子类型,所以报错了(详见本章后文)。
但是,反过来是可以的,子类型可以赋值给父类型。
let x: 5 = 5
let y: number = 4 + 1
x = y // 报错
y = x // 正确如果一定要让子类型可以赋值为父类型的值,就要用到类型断言。
const x: 5 = (4 + 1) as 5 // 正确上面示例,在 4 + 1 后面加上 as 5,就是告诉编译器,可以把 4 + 1 的类型视为值类型 5,这样就不会报错了。
只包含单个值的值类型,用处不大。实际开发中,往往将多个值结合,作为联合类型使用。
联合类型
联合类型(union types)指的是多个类型组成的一个新类型,使用符号 | 表示。
联合类型 A | B 表示,任何一个类型只要属于 A 或 B,就属于联合类型 A | B。
let x: string | number
x = 123
x = 'abc'联合类型与值类型结合,表示一个变量的值有若干种可能。
let setting: true | false
let gender: 'male' | 'female'
let rainbowColor: '赤' | '橙' | '黄' | '绿' | '青' | '蓝' | '紫'上面的示例都是由值类型组成的联合类型,非常清楚地表达了变量的取值范围。其中,true | false 其实就是布尔类型 boolean。
前面提到,打开编译选项 strictNullChecks 后,其他类型的变量不能赋值为 undefined 或 null。这时,如果某个变量确实可能包含空值,就可以采用联合类型的写法。
let name: string | null
name = 'John'
name = null如果一个变量有多种类型,读取该变量时,往往需要进行“类型缩小”(type narrowing),区分该值到底属于哪一种类型,然后再进一步处理。
function printId(id: number | string) {
console.log(id.toUpperCase()) // 报错
}上面示例中,参数变量 id 可能是数值,也可能是字符串,这时直接对这个变量调用 toUpperCase() 方法会报错,因为这个方法只存在于字符串,不存在于数值。
解决方法就是对参数 id 做一下类型缩小,确定它的类型以后再进行处理。
function printId(id: number | string) {
if (typeof id === 'string') {
console.log(id.toUpperCase())
} else {
console.log(id)
}
}“类型缩小”是 TypeScript 处理联合类型的标准方法,凡是遇到可能为多种类型的场合,都需要先缩小类型,再进行处理。实际上,联合类型本身可以看成是一种“类型放大”(type widening),处理时就需要“类型缩小”。
下面是“类型缩小”的另一个例子:
function getPort(scheme: 'http' | 'https') {
switch (scheme) {
case 'http':
return 80
case 'https':
return 443
}
}交叉类型
交叉类型(intersection types)指的多个类型组成的一个新类型,使用符号 & 表示。
交叉类型 A & B 表示,任何一个类型必须同时属于 A 和 B,才属于交叉类型 A & B,即交叉类型同时满足 A 和 B 的特征。
let x: number & string上面示例中,变量 x 同时是数值和字符串,这当然是不可能的,所以 TypeScript 认为 x 的类型实际是 never。
交叉类型的主要用途是表示对象的合成:
let obj: { foo: string } & { bar: string }
obj = {
foo: 'hello',
bar: 'world'
}交叉类型常常用来为对象类型添加新属性:
type A = { foo: number }
type B = A & { bar: number }type 命令
type 命令用来定义一个类型的别名。
type Age = number
let age: Age = 55上面示例中,type 命令为 number 类型定义了一个别名 Age。这样就能像使用 number 一样,使用 Age 作为类型。
别名可以让类型的名字变得更有意义,也能增加代码的可读性,还可以使复杂类型用起来更方便,便于以后修改变量的类型。
别名不允许重名。
type Color = 'red'
type Color = 'blue' // 报错别名的作用域是块级作用域。这意味着,代码块内部定义的别名,影响不到外部。
type Color = 'red'
if (Math.random() < 0.5) {
type Color = 'blue'
}上面示例中,if 代码块内部的类型别名 Color,跟外部的 Color 是不一样的。
别名支持使用表达式,也可以在定义一个别名时,使用另一个别名,即别名允许嵌套。
type World = 'world'
type Greeting = `hello ${World}`上面示例中,别名 Greeting 使用了模板字符串,读取另一个别名 World。
注意
type 命令属于类型相关的代码,编译成 JavaScript 的时候,会被全部删除。
typeof 运算符
JavaScript 语言中,typeof 运算符是一个一元运算符,返回一个字符串,代表操作数的类型。
typeof 'foo' // string注意,这时 typeof 的操作数是一个值。
JavaScript 里面,typeof 运算符只可能返回八种结果,而且都是字符串。
typeof undefined // "undefined"
typeof true // "boolean"
typeof 1337 // "number"
typeof 'foo' // "string"
typeof {} // "object"
typeof parseInt // "function"
typeof Symbol() // "symbol"
typeof 127n // "bigint"TypeScript 将 typeof 运算符移植到了类型运算,它的操作数依然是一个值,但是返回的不是字符串,而是该值的 TypeScript 类型。
const a = { x: 0 }
type T0 = typeof a // { x :number }
type T1 = typeof a.x // number这种用法的 typeof 返回的是 TypeScript 类型,所以只能用在类型运算之中(即跟类型相关的代码之中),不能用在值运算。
也就是说,同一段代码可能存在两种 typeof 运算符,一种用在值相关的 JavaScript 代码部分,另一种用在类型相关的 TypeScript 代码部分,
let a = 1
let b: typeof a
if (typeof a === 'number') {
b = a
}上面示例中,用到了两个 typeof,第一个是类型运算,第二个是值运算。它们是不一样的,不要混淆。
JavaScript 的 typeof 遵守 JavaScript 规则,TypeScript 的 typeof 遵守 TypeScript 规则。它们的一个重要区别在于,编译后,前者会保留,后者会被全部删除。
上面代码的编译结果如下:
let a = 1
let b
if (typeof a === 'number') {
b = a
}上面示例中,只保留的原始代码的第二个 typeof,删除了第一个 typeof。
由于编译时不会进行 JavaScript 的值运算,所以 TypeScript 规定,typeof 的参数只能是标识符,不能是需要运算的表达式。
type T = typeof Date() // 报错上面示例会报错,原因是 typeof 的参数不能是一个值的运算式,而 Date() 需要运算才知道结果。
另外,typeof 命令的参数不能是类型。
type Age = number
type MyAge = typeof Age // 报错typeof 是一个很重要的 TypeScript 运算符,有些场合不知道某个变量 foo 的类型,这时使用 typeof foo 就可以获得它的类型。
块级类型声明
TypeScript 支持块级类型声明,即类型可以声明在代码块(用大括号表示)里面,并且只在当前代码块有效。
if (true) {
type T = number
let v: T = 5
} else {
type T = string
let v: T = 'hello'
}上面示例中,存在两个代码块,其中分别有一个类型 T 的声明。这两个声明都只在自己的代码块内部有效,在代码块外部无效。
类型的兼容
TypeScript 的类型存在兼容关系,某些类型可以兼容其他类型。
type T = number | string
let a: number = 1
let b: T = a上面示例中,变量 a 和 b 的类型是不一样的,但是变量 a 赋值给变量 b 并不会报错。这时,我们就认为, b 的类型兼容 a 的类型。
TypeScript 为这种情况定义了一个专门术语。如果类型 A 的值可以赋值给类型 B,那么类型 A 就称为类型 B 的子类型(subtype)。在上例中,类型 number 就是类型 number | string 的子类型。
TypeScript 的一个规则是,凡是可以用到父类型的地方,都可以使用子类型,但是反过来不行。
let a: 'hi' = 'hi'
let b: string = 'hello'
b = a // 正确
a = b // 报错上面示例中,hi 是 string 的子类型,string 是 hi 的父类型。所以,变量 a 可以赋值给变量 b,但是反过来就会报错。
之所以有这样的规则,是因为子类型继承了父类型的所有特征,所以可以用在父类型的场合。但是,子类型可能有一些父类型没有的特征,所以父类型不能用在子类型的场合。