TypeScript 的对象类型
对象类型的最简单声明方法,就是使用大括号表示对象
TypeScript 的对象类型
简介
除了原始类型,对象是 JavaScript 最基本的数据结构。TypeScript 对于对象类型有很多规则。
对象类型的最简单声明方法,就是使用大括号表示对象,在大括号内部声明每个属性和方法的类型。
上面示例中,对象obj
的类型就写在变量名后面,使用大括号描述,内部声明每个属性的属性名和类型。
属性的类型可以用分号结尾,也可以用逗号结尾。
最后一个属性后面,可以写分号或逗号,也可以不写。
一旦声明了类型,对象赋值时,就不能缺少指定的属性,也不能有多余的属性。
上面示例中,变量o1
缺少了属性y
,变量o2
多出了属性z
,都会报错。
读写不存在的属性也会报错。
上面示例中,读写不存在的属性z
都会报错。
同样地,也不能删除类型声明中存在的属性,修改属性值是可以的。
上面声明中,删除类型声明中存在的属性name
会报错,但是可以修改它的值。
对象的方法使用函数类型描述。
上面示例中,对象obj
有一个方法add()
,需要定义它的参数类型和返回值类型。
对象类型可以使用方括号读取属性的类型。
上面示例中,对象类型User
使用方括号,读取了属性name
的类型(string
)。
除了type
命令可以为对象类型声明一个别名,TypeScript 还提供了interface
命令,可以把对象类型提炼为一个接口。
上面示例中,写法一是type
命令的用法,写法二是interface
命令的用法。interface
命令的详细解释,以及与type
命令的区别,详见《Interface》一章。
注意,TypeScript 不区分对象自身的属性和继承的属性,一律视为对象的属性。
上面示例中,obj
只写了prop
属性,但是不报错。因为它可以继承原型上面的toString()
方法。
可选属性
如果某个属性是可选的(即可以忽略),需要在属性名后面加一个问号。
上面示例中,属性y
是可选的。
可选属性等同于允许赋值为undefined
,下面两种写法是等效的。
上面示例中,类型User
的可选属性lastName
可以是字符串,也可以是undefined
,即可选属性可以赋值为undefined
。
上面示例中,可选属性y
赋值为undefined
,不会报错。
同样地,读取一个没有赋值的可选属性时,返回undefined
。
上面示例中,最后一行会报错,因为obj.y
返回undefined
,无法对其调用toLowerCase()
。
所以,读取可选属性之前,必须检查一下是否为undefined
。
上面示例中,lastName
是可选属性,需要判断是否为undefined
以后,才能使用。建议使用下面的写法。
上面示例中,写法一使用三元运算符?:
,判断是否为undefined
,并设置默认值。写法二使用 Null 判断运算符??
,与写法一的作用完全相同。
TypeScript 提供编译设置ExactOptionalPropertyTypes
,只要同时打开这个设置和strictNullChecks
,可选属性就不能设为undefined
。
上面示例中,打开了这两个设置以后,可选属性就不能设为undefined
了。
注意,可选属性与允许设为undefined
的必选属性是不等价的。
上面示例中,属性y
如果是一个可选属性,那就可以省略不写;如果是允许设为undefined
的必选属性,一旦省略就会报错,必须显式写成{ x: 1, y: undefined }
。
只读属性
属性名前面加上readonly
关键字,表示这个属性是只读属性,不能修改。
上面示例中,prop
属性是只读属性,不能修改它的值。
上面示例中,最后一行修改了只读属性age
,就报错了。
只读属性只能在对象初始化期间赋值,此后就不能修改该属性。
上面示例中,类型Point
的属性x
和y
都带有修饰符readonly
,表示这两个属性只能在初始化期间赋值,后面再修改就会报错。
注意,如果属性值是一个对象,readonly
修饰符并不禁止修改该对象的属性,只是禁止完全替换掉该对象。
上面示例中,h.resident
是只读属性,它的值是一个对象。修改这个对象的age
属性是可以的,但是整个替换掉h.resident
属性会报错。
另一个需要注意的地方是,如果一个对象有两个引用,即两个变量对应同一个对象,其中一个变量是可写的,另一个变量是只读的,那么从可写变量修改属性,会影响到只读变量。
上面示例中,变量w
和r
指向同一个对象,其中w
是可写的,r
是只读的。那么,对w
的属性修改,会影响到r
。
如果希望属性值是只读的,除了声明时加上readonly
关键字,还有一种方法,就是在赋值时,在对象后面加上只读断言as const
。
上面示例中,对象后面加了只读断言as const
,就变成只读对象了,不能修改属性了。
注意,上面的as const
属于 TypeScript 的类型推断,如果变量明确地声明了类型,那么 TypeScript 会以声明的类型为准。
上面示例中,根据变量myUser
的类型声明,name
不是只读属性,但是赋值时又使用只读断言as const
。这时会以声明的类型为准,因为name
属性可以修改。
属性名的索引类型
如果对象的属性非常多,一个个声明类型就很麻烦,而且有些时候,无法事前知道对象会有多少属性,比如外部 API 返回的对象。这时 TypeScript 允许采用属性名表达式的写法来描述类型,称为“属性名的索引类型”。
索引类型里面,最常见的就是属性名的字符串索引。
上面示例中,类型MyObj
的属性名类型就采用了表达式形式,写在方括号里面。[property: string]
的property
表示属性名,这个是可以随便起的,它的类型是string
,即属性名类型为string
。也就是说,不管这个对象有多少属性,只要属性名为字符串,且属性值也是字符串,就符合这个类型声明。
JavaScript 对象的属性名(即上例的property
)的类型有三种可能,除了上例的string
,还有number
和symbol
。
上面示例中,对象属性名的类型分别为number
和symbol
。
上面示例中,对象类型MyArr
的属性名是[n:number]
,就表示它的属性名都是数值,比如0
、1
、2
。
对象可以同时有多种类型的属性名索引,比如同时有数值索引和字符串索引。但是,数值索引不能与字符串索引发生冲突,必须服从后者,这是因为在 JavaScript 语言内部,所有的数值属性名都会自动转为字符串属性名。
上面示例中,类型MyType
同时有两种属性名索引,但是数值索引与字符串索引冲突了,所以报错了。由于字符属性名的值类型是string
,数值属性名的值类型只有同样为string
,才不会报错。
同样地,可以既声明属性名索引,也声明具体的单个属性名。如果单个属性名不符合属性名索引的范围,两者发生冲突,就会报错。
上面示例中,属性名foo
符合属性名的字符串索引,但是两者的属性值类型不一样,所以报错了。
属性的索引类型写法,建议谨慎使用,因为属性名的声明太宽泛,约束太少。另外,属性名的数值索引不宜用来声明数组,因为采用这种方式声明数组,就不能使用各种数组方法以及length
属性,因为类型里面没有定义这些东西。
上面示例中,读取arr.length
属性会报错,因为类型MyArr
没有这个属性。
解构赋值
解构赋值用于直接从对象中提取属性。
上面语句从对象product
提取了三个属性,并声明属性名的同名变量。
解构赋值的类型写法,跟为对象声明类型是一样的。
注意,目前没法为解构变量指定类型,因为对象解构里面的冒号,JavaScript 指定了其他用途。
上面示例中,冒号不是表示属性x
和y
的类型,而是为这两个属性指定新的变量名。如果要为x
和y
指定类型,不得不写成下面这样。
这一点要特别小心,TypeScript 里面很容易搞糊涂。
上面示例中,函数draw()
的参数是一个对象解构,里面的冒号很像是为变量指定类型,其实是为对应的属性指定新的变量名。所以,TypeScript 就会解读成,函数体内不存在变量shape
,而是属性shape
的值被赋值给了变量Shape
。
结构类型原则
只要对象 B 满足 对象 A 的结构特征,TypeScript 就认为对象 B 兼容对象 A 的类型,这称为“结构类型”原则(structural typing)。
上面示例中,对象A
只有一个属性x
,类型为number
。对象B
满足这个特征,因此兼容对象A
,只要可以使用A
的地方,就可以使用B
。
上面示例中,A
和B
并不是同一个类型,但是B
可以赋值给A
,因为B
满足A
的结构特征。
根据“结构类型”原则,TypeScript 检查某个值是否符合指定类型时,并不是检查这个值的类型名(即“名义类型”),而是检查这个值的结构是否符合要求(即“结构类型”)。
TypeScript 之所以这样设计,是为了符合 JavaScript 的行为。JavaScript 并不关心对象是否严格相似,只要某个对象具有所要求的属性,就可以正确运行。
如果类型 B 可以赋值给类型 A,TypeScript 就认为 B 是 A 的子类型(subtyping),A 是 B 的父类型。子类型满足父类型的所有结构特征,同时还具有自己的特征。凡是可以使用父类型的地方,都可以使用子类型,即子类型兼容父类型。
这种设计有时会导致令人惊讶的结果。
上面示例中,函数getSum()
要求传入参数的类型是myObj
,但是实际上所有与myObj
兼容的对象都可以传入。这会导致const v = obj[n]
这一行报错,原因是obj[n]
取出的属性值不一定是数值(number
),使得变量v
的类型被推断为any
。如果项目设置为不允许变量类型推断为any
,代码就会报错。写成下面这样,就不会报错。
上面示例就不会报错,因为函数体内部只使用了属性x
和y
,这两个属性有明确的类型声明,保证obj.x
和obj.y
肯定是数值。虽然与MyObj
兼容的任何对象都可以传入函数getSum()
,但是只要不使用其他属性,就不会有类型报错。
严格字面量检查
如果对象使用字面量表示,会触发 TypeScript 的严格字面量检查(strict object literal checking)。如果字面量的结构跟类型定义的不一样(比如多出了未定义的属性),就会报错。
上面示例中,等号右边是一个对象的字面量,这时会触发严格字面量检查。只要有类型声明中不存在的属性(本例是z
),就会导致报错。
如果等号右边不是字面量,而是一个变量,根据结构类型原则,是不会报错的。
上面示例中,等号右边是一个变量,就不会触发严格字面量检查,从而不报错。
TypeScript 对字面量进行严格检查的目的,主要是防止拼写错误。一般来说,字面量大多数来自手写,容易出现拼写错误,或者误用 API。
上面示例中,属性darkMode
拼写错了,成了darkmode
。如果没有严格字面量规则,就不会报错,因为darkMode
是可选属性,根据结构类型原则,任何对象只要有title
属性,都认为符合Options
类型。
规避严格字面量检查,可以使用中间变量。
上面示例中,创建了一个中间变量myOptions
,就不会触发严格字面量规则,因为这时变量obj
的赋值,不属于直接字面量赋值。
如果你确认字面量没有错误,也可以使用类型断言规避严格字面量检查。
上面示例使用类型断言as Options
,告诉编译器,字面量符合 Options 类型,就能规避这条规则。
如果允许字面量有多余属性,可以像下面这样在类型里面定义一个通用属性。
上面示例中,变量x
的类型声明里面,有一个属性的字符串索引([x: string]
),导致任何字符串属性名都是合法的。
由于严格字面量检查,字面量对象传入函数必须很小心,不能有多余的属性。
上面示例中,对象字面量传入函数computeDistance()
时,不能有多余的属性,否则就通不过严格字面量检查。
编译器选项suppressExcessPropertyErrors
,可以关闭多余属性检查。下面是它在 tsconfig.json 文件里面的写法。
最小可选属性规则
根据“结构类型”原则,如果一个对象的所有属性都是可选的,那么其他对象跟它都是结构类似的。
上面示例中,类型Options
的所有属性都是可选的,所以它可以是一个空对象,也就意味着任意对象都满足Options
的结构。
为了避免这种情况,TypeScript 2.4 引入了一个“最小可选属性规则”,也称为“弱类型检测”(weak type detection)。
上面示例中,对象opts
与类型Options
没有共同属性,赋值给该类型的变量就会报错。
报错原因是,如果某个类型的所有属性都是可选的,那么该类型的对象必须至少存在一个可选属性,不能所有可选属性都不存在。这就叫做“最小可选属性规则”。
如果想规避这条规则,要么在类型里面增加一条索引属性([propName: string]: someType
),要么使用类型断言(opts as Options
)。
空对象
空对象是 TypeScript 的一种特殊值,也是一种特殊类型。
上面示例中,变量obj
的值是一个空对象,然后对obj.prop
赋值就会报错。
原因是这时 TypeScript 会推断变量obj
的类型为空对象,实际执行的是下面的代码。
空对象没有自定义属性,所以对自定义属性赋值就会报错。空对象只能使用继承的属性,即继承自原型对象Object.prototype
的属性。
上面示例中,toString()
方法是一个继承自原型对象的方法,TypeScript 允许在空对象上使用。
回到本节开始的例子,这种写法其实在 JavaScript 很常见:先声明一个空对象,然后向空对象添加属性。但是,TypeScript 不允许动态添加属性,所以对象不能分步生成,必须生成时一次性声明所有属性。
如果确实需要分步声明,一个比较好的方法是,使用扩展运算符(...
)合成一个新对象。
上面示例中,对象pt
是三个部分合成的,这样既可以分步声明,也符合 TypeScript 静态声明的要求。
空对象作为类型,其实是Object
类型的简写形式。
上面示例中,各种类型的值(除了null
和undefined
)都可以赋值给空对象类型,跟Object
类型的行为是一样的。
因为Object
可以接受各种类型的值,而空对象是Object
类型的简写,所以它不会有严格字面量检查,赋值时总是允许多余的属性,只是不能读取这些属性。
上面示例中,变量b
的类型是空对象,视同Object
类型,不会有严格字面量检查,但是读取多余的属性会报错。
如果想强制使用没有任何属性的对象,可以采用下面的写法。
上面的示例中,[key: string]: never
表示字符串属性名是不存在的,因此其他对象进行赋值时就会报错。