TypeScript 的对象类型

简介

除了原始类型,对象是 JavaScript 最基本的数据结构。TypeScript 对于对象类型有很多规则。

对象类型的最简单声明方法,就是使用大括号表示对象,在大括号内部声明每个属性和方法的类型。

  1. const obj:{
  2. x:number;
  3. y:number;
  4. } = { x: 1, y: 1 };

上面示例中,对象obj的类型就写在变量名后面,使用大括号描述,内部声明每个属性的属性名和类型。

属性的类型可以用分号结尾,也可以用逗号结尾。

  1. // 属性类型以分号结尾
  2. type MyObj = {
  3. x:number;
  4. y:number;
  5. };
  6. // 属性类型以逗号结尾
  7. type MyObj = {
  8. x:number,
  9. y:number,
  10. };

最后一个属性后面,可以写分号或逗号,也可以不写。

一旦声明了类型,对象赋值时,就不能缺少指定的属性,也不能有多余的属性。

  1. type MyObj = {
  2. x:number;
  3. y:number;
  4. };
  5. const o1:MyObj = { x: 1 }; // 报错
  6. const o2:MyObj = { x: 1, y: 1, z: 1 }; // 报错

上面示例中,变量o1缺少了属性y,变量o2多出了属性z,都会报错。

读写不存在的属性也会报错。

  1. const obj:{
  2. x:number;
  3. y:number;
  4. } = { x: 1, y: 1 };
  5. console.log(obj.z); // 报错
  6. obj.z = 1; // 报错

上面示例中,读写不存在的属性z都会报错。

同样地,也不能删除类型声明中存在的属性,修改属性值是可以的。

  1. const myUser = {
  2. name: "Sabrina",
  3. };
  4. delete myUser.name // 报错
  5. myUser.name = "Cynthia"; // 正确

上面声明中,删除类型声明中存在的属性name会报错,但是可以修改它的值。

对象的方法使用函数类型描述。

  1. const obj:{
  2. x: number;
  3. y: number;
  4. add(x:number, y:number): number;
  5. // 或者写成
  6. // add: (x:number, y:number) => number;
  7. } = {
  8. x: 1,
  9. y: 1,
  10. add(x, y) {
  11. return x + y;
  12. }
  13. };

上面示例中,对象obj有一个方法add(),需要定义它的参数类型和返回值类型。

对象类型可以使用方括号读取属性的类型。

  1. type User = {
  2. name: string,
  3. age: number
  4. };
  5. type Name = User['name']; // string

上面示例中,对象类型User使用方括号,读取了属性name的类型(string)。

除了type命令可以为对象类型声明一个别名,TypeScript 还提供了interface命令,可以把对象类型提炼为一个接口。

  1. // 写法一
  2. type MyObj = {
  3. x:number;
  4. y:number;
  5. };
  6. const obj:MyObj = { x: 1, y: 1 };
  7. // 写法二
  8. interface MyObj {
  9. x: number;
  10. y: number;
  11. }
  12. const obj:MyObj = { x: 1, y: 1 };

上面示例中,写法一是type命令的用法,写法二是interface命令的用法。interface命令的详细解释,以及与type命令的区别,详见《Interface》一章。

注意,TypeScript 不区分对象自身的属性和继承的属性,一律视为对象的属性。

  1. interface MyInterface {
  2. toString(): string; // 继承的属性
  3. prop: number; // 自身的属性
  4. }
  5. const obj:MyInterface = { // 正确
  6. prop: 123,
  7. };

上面示例中,obj只写了prop属性,但是不报错。因为它可以继承原型上面的toString()方法。

可选属性

如果某个属性是可选的(即可以忽略),需要在属性名后面加一个问号。

  1. const obj: {
  2. x: number;
  3. y?: number;
  4. } = { x: 1 };

上面示例中,属性y是可选的。

可选属性等同于允许赋值为undefined,下面两种写法是等效的。

  1. type User = {
  2. firstName: string;
  3. lastName?: string;
  4. };
  5. // 等同于
  6. type User = {
  7. firstName: string;
  8. lastName: string|undefined;
  9. };

上面示例中,类型User的属性lastName可以是字符串,也可以是undefined,就表示该属性可以省略不写。

同理,读取一个可选属性时,有可能返回undefined

  1. type MyObj = {
  2. x: string,
  3. y?: string
  4. };
  5. const obj:MyObj = { x: 'hello' };
  6. obj.y.toLowerCase() // 报错

上面示例中,最后一行会报错,因为obj.y有可能是undefined,无法对其调用toLowerCase()

所以,读取可选属性之前,必须检查一下是否为undefined

  1. const user:{
  2. firstName: string;
  3. lastName?: string;
  4. } = { firstName: 'Foo'};
  5. if (user.lastName !== undefined) {
  6. console.log(`hello ${user.firstName} ${user.lastName}`)
  7. }

上面示例中,lastName是可选属性,需要判断是否为undefined以后,才能使用。建议使用下面的写法。

  1. // 写法一
  2. let firstName = (user.firstName === undefined)
  3. ? 'Foo' : user.firstName;
  4. let lastName = (user.lastName === undefined)
  5. ? 'Bar' : user.lastName;
  6. // 写法二
  7. let firstName = user.firstName ?? 'Foo';
  8. let lastName = user.lastName ?? 'Bar';

上面示例中,写法一使用三元运算符?:,判断是否为undefined,并设置默认值。写法二使用 Null 判断运算符??,与写法一的作用完全相同。

只读属性

属性名前面加上readonly关键字,表示这个属性是只读属性,不能修改。

  1. interface MyInterface {
  2. readonly prop: number;
  3. }

上面示例中,prop属性是只读属性,不能修改它的值。

  1. const person:{
  2. readonly age: number
  3. } = { age: 20 };
  4. person.age = 21; // 报错

上面示例中,最后一行修改了只读属性age,就报错了。

只读属性只能在对象初始化期间赋值,此后就不能修改该属性。

  1. type Point = {
  2. readonly x: number;
  3. readonly y: number;
  4. };
  5. const p:Point = { x: 0, y: 0 };
  6. p.x = 100; // 报错

上面示例中,类型Point的属性xy都带有修饰符readonly,表示这两个属性只能在初始化期间赋值,后面再修改就会报错。

注意,如果属性值是一个对象,readonly修饰符并不禁止修改该对象的属性,只是禁止完全替换掉该对象。

  1. interface Home {
  2. readonly resident: {
  3. name: string;
  4. age: number
  5. };
  6. }
  7. const h:Home = {
  8. resident: {
  9. name: 'Vicky',
  10. age: 42
  11. }
  12. };
  13. h.resident.age = 32; // 正确
  14. h.resident = {
  15. name: 'Kate',
  16. age: 23
  17. } // 报错

上面示例中,h.resident是只读属性,它的值是一个对象。修改这个对象的age属性是可以的,但是整个替换掉h.resident属性会报错。

另一个需要注意的地方是,如果一个对象有两个引用,即两个变量对应同一个对象,其中一个变量是可写的,另一个变量是只读的,那么从可写变量修改属性,会影响到只读变量。

  1. interface Person {
  2. name: string;
  3. age: number;
  4. }
  5. interface ReadonlyPerson {
  6. readonly name: string;
  7. readonly age: number;
  8. }
  9. let w:Person = {
  10. name: 'Vicky',
  11. age: 42,
  12. };
  13. let r:ReadonlyPerson = w;
  14. w.age += 1;
  15. r.age // 43

上面示例中,变量wr指向同一个对象,其中w是可写的,r的只读的。那么,对w的属性修改,会影响到r

如果希望属性值是只读的,除了声明时加上readonly关键字,还有一种方法,就是在赋值时,在对象后面加上只读断言as const

  1. const myUser = {
  2. name: "Sabrina",
  3. } as const;
  4. myUser.name = "Cynthia"; // 报错

上面示例中,对象后面加了只读断言as const,就变成只读对象了,不能修改属性了。

注意,上面的as const属于 TypeScript 的类型推断,如果变量明确地声明了类型,那么 TypeScript 会以声明的类型为准。

  1. const myUser:{ name: string } = {
  2. name: "Sabrina",
  3. } as const;
  4. myUser.name = "Cynthia"; // 正确

上面示例中,根据变量myUser的类型声明,name不是只读属性,但是赋值时又使用只读断言as const。这时会以声明的类型为准,因为name属性可以修改。

属性名的索引类型

如果对象的属性非常多,一个个声明类型就很麻烦,而且有些时候,无法事前知道对象会有多少属性,比如外部 API 返回的对象。这时 TypeScript 允许采用属性名表达式的写法来描述类型,称为“属性名的索引类型”。

索引类型里面,最常见的就是属性名的字符串索引。

  1. type MyObj = {
  2. [property: string]: string
  3. };
  4. const obj:MyObj = {
  5. foo: 'a',
  6. bar: 'b',
  7. baz: 'c',
  8. };

上面示例中,类型MyObj的属性名类型就采用了表达式形式,写在方括号里面。[property: string]property表示属性名,这个是可以随便起的,它的类型是string,即属性名类型为string。也就是说,不管这个对象有多少属性,只要属性名为字符串,且属性值也是字符串,就符合这个类型声明。

JavaScript 对象的属性名(即上例的property)的类型有三种可能,除了上例的string,还有numbersymbol

  1. type T1 = {
  2. [property: number]: string
  3. };
  4. type T2 = {
  5. [property: symbol]: string
  6. };

上面示例中,对象属性名的类型分别为numbersymbol

  1. type MyArr = {
  2. [n:number]: number;
  3. };
  4. const arr:MyArr = [1, 2, 3];
  5. // 或者
  6. const arr:MyArr = {
  7. 0: 1,
  8. 1: 2,
  9. 2: 3,
  10. };

上面示例中,对象类型MyArr的属性名是[n:number],就表示它的属性名都是数值,比如012

对象可以同时有多种类型的属性名索引,比如同时有数值索引和字符串索引。但是,数值索引不能与字符串索引发生冲突,必须服从后者,这是因为在 JavaScript 语言内部,所有的数值属性名都会自动转为字符串属性名。

  1. type MyType = {
  2. [x: number]: boolean; // 报错
  3. [x: string]: string;
  4. }

上面示例中,类型MyType同时有两种属性名索引,但是数值索引与字符串索引冲突了,所以报错了。由于字符属性名的值类型是string,数值属性名的值类型只有同样为string,才不会报错。

同样地,可以既声明属性名索引,也声明具体的单个属性名。如果单个属性名符合属性名索引的范围,两者不能有冲突,否则报错。

  1. type MyType = {
  2. foo: boolean; // 报错
  3. [x: string]: string;
  4. }

上面示例中,属性名foo符合属性名的字符串索引,但是两者的属性值类型不一样,所以报错了。

属性的索引类型写法,建议谨慎使用,因为属性名的声明太宽泛,约束太少。另外,属性名的数值索引不宜用来声明数组,因为采用这种方式声明数组,就不能使用各种数组方法以及length属性,因为类型里面没有定义这些东西。

  1. type MyArr = {
  2. [n:number]: number;
  3. };
  4. const arr:MyArr = [1, 2, 3];
  5. arr.length // 报错

上面示例中,读取arr.length属性会报错,因为类型MyArr没有这个属性。

解构赋值

解构赋值用于直接从对象中提取属性。

  1. const {id, name, price} = product;

上面语句从对象product提取了三个属性,并声明属性名的同名变量。

解构赋值的类型写法,跟为对象声明类型是一样的。

  1. const {id, name, price}:{
  2. id: string;
  3. name: string;
  4. price: number
  5. } = product;

注意,目前没法为解构变量指定类型,因为对象解构里面的冒号,JavaScript 指定了其他用途。

  1. let { x: foo, y: bar } = obj;
  2. // 等同于
  3. let foo = obj.x;
  4. let bar = obj.y;

上面示例中,冒号不是表示属性xy的类型,而是为这两个属性指定新的变量名。如果要为xy指定类型,不得不写成下面这样。

  1. let { x: foo, y: bar }
  2. : { x: string; y: number } = obj;

这一点要特别小心,TypeScript 里面很容易搞糊涂。

  1. function draw({
  2. shape: Shape,
  3. xPos: number = 100,
  4. yPos: number = 100
  5. }) {
  6. let myShape = shape; // 报错
  7. let x = xPos; // 报错
  8. }

上面示例中,函数draw()的参数是一个对象解构,里面的冒号很像是为变量指定类型,其实是为对应的属性指定新的变量名。所以,TypeScript 就会解读成,函数体内不存在变量shape,而是属性shape的值被赋值给了变量Shape

结构类型原则

只要对象 B 满足 对象 A 的结构特征,TypeScript 就认为对象 B 兼容对象 A 的类型,这称为“结构类型”原则(structual typing)。

  1. const A = {
  2. x: number;
  3. };
  4. const B = {
  5. x: number;
  6. y: number;
  7. };

上面示例中,对象A只有一个属性x,类型为number。对象B满足这个特征,因此兼容对象A,只要可以使用A的地方,就可以使用B

  1. const B = {
  2. x: 1,
  3. y: 1
  4. };
  5. const A:{ x: number } = B; // 正确

上面示例中,AB并不是同一个类型,但是B可以赋值给A,因为B满足A的结构特征。

根据“结构类型”原则,TypeScript 检查某个值是否符合指定类型时,并不是检查这个值的类型名(即“名义类型”),而是检查这个值的结构是否符合要求(即“结构类型”)。

TypeScript 之所以这样设计,是为了符合 JavaScript 的行为。JavaScript 并不关心对象是否严格相似,只要某个对象具有所要求的属性,就可以正确运行。

如果类型 B 可以赋值给类型 A,TypeScript 就认为 B 是 A 的子类型(subtyping),A 是 B 的父类型。子类型满足父类型的所有结构特征,同时还具有自己的特征。凡是可以使用父类型的地方,都可以使用子类型,即子类型兼容父类型。

这种设计有时会导致令人惊讶的结果。

  1. type myObj = {
  2. x: number,
  3. y: number,
  4. };
  5. function getSum(obj:myObj) {
  6. let sum = 0;
  7. for (const n of Object.keys(obj)) {
  8. const v = obj[n]; // 报错
  9. sum += Math.abs(v);
  10. }
  11. return sum;
  12. }

上面示例中,函数getSum()要求传入参数的类型是myObj,但是实际上所有与myObj兼容的对象都可以传入。这会导致const v = obj[n]这一行报错,原因是obj[n]取出的属性值不一定是数值(number),使得变量v的类型被推断为any。如果项目设置为不允许变量类型推断为any,代码就会报错。写成下面这样,就不会报错。

  1. type MyObj = {
  2. x: number,
  3. y: number,
  4. };
  5. function getSum(obj:MyObj) {
  6. return Math.abs(obj.x) + Math.abs(obj.y);
  7. }

上面示例就不会报错,因为函数体内部只使用了属性xy,这两个属性有明确的类型声明,保证obj.xobj.y肯定是数值。虽然与MyObj兼容的任何对象都可以传入函数getSum(),但是只要不使用其他属性,就不会有类型报错。

严格字面量检查

如果对象使用字面量表示,会触发 TypeScript 的严格字面量检查(strict object literal checking)。如果字面量的结构跟类型定义的不一样(比如多出了未定义的属性),就会报错。

  1. const point:{
  2. x:number;
  3. y:number;
  4. } = {
  5. x: 1,
  6. y: 1,
  7. z: 1 // 报错
  8. };

上面示例中,等号右边是一个对象的字面量,这时会触发严格字面量检查。只要有类型声明中不存在的属性(本例是z),就会导致报错。

如果等号右边不是字面量,而是一个变量,根据结构类型原则,是不会报错的。

  1. const myPoint = {
  2. x: 1,
  3. y: 1,
  4. z: 1
  5. };
  6. const point:{
  7. x:number;
  8. y:number;
  9. } = myPoint; // 正确

上面示例中,等号右边是一个变量,就不会触发严格字面量检查,从而不报错。

TypeScript 对字面量进行严格检查的目的,主要是防止拼写错误。一般来说,字面量大多数来自手写,容易出现拼写错误,或者误用 API。

  1. type Options = {
  2. title:string;
  3. darkMode?:boolean;
  4. };
  5. const Obj:Options = {
  6. title: '我的网页',
  7. darkmode: true, // 报错
  8. };

上面示例中,属性darkMode拼写错了,成了darkmode。如果没有严格字面量规则,就不会报错,因为darkMode是可选属性,根据结构类型原则,任何对象只要有title属性,都认为符合Options类型。

规避严格字面量检查,可以使用中间变量。

  1. let myOptions = {
  2. title: '我的网页',
  3. darkmode: true,
  4. };
  5. const Obj:Options = myOptions;

上面示例中,创建了一个中间变量myOptions,就不会触发严格字面量规则,因为这时变量obj的赋值,不属于直接字面量赋值。

如果你确认字面量没有错误,也可以使用类型断言规避严格字面量检查。

  1. const Obj:Options = {
  2. title: '我的网页',
  3. darkmode: true,
  4. } as Options;

上面示例使用类型断言as Options,告诉编译器,字面量符合 Options 类型,就能规避这条规则。

如果允许字面量有多余属性,可以像下面这样在类型里面定义一个通用属性。

  1. let x: {
  2. foo: number,
  3. [x: string]: any
  4. };
  5. x = { foo: 1, baz: 2 }; // Ok

上面示例中,变量x的类型声明里面,有一个属性的字符串索引([x: string]),导致任何字符串属性名都是合法的。

由于严格字面量检查,字面量对象传入函数必须很小心,不能有多余的属性。

  1. interface Point {
  2. x: number;
  3. y: number;
  4. }
  5. function computeDistance(point: Point) { /*...*/ }
  6. computeDistance({ x: 1, y: 2, z: 3 }); // 报错
  7. computeDistance({x: 1, y: 2}); // 正确

上面示例中,对象字面量传入函数computeDistance()时,不能有多余的属性,否则就通不过严格字面量检查。

编译器选项suppressExcessPropertyErrors,可以关闭多余属性检查。下面是它在 tsconfig.json 文件里面的写法。

  1. {
  2. "compilerOptions": {
  3. "suppressExcessPropertyErrors": true
  4. }
  5. }

最小可选属性规则

如果一个对象的所有属性都是可选的,会触发最小可选属性规则。

  1. type Options = {
  2. a?:number;
  3. b?:number;
  4. c?:number;
  5. };
  6. const obj:Options = {
  7. d: 123 // 报错
  8. };

上面示例中,类型Options是一个对象,它的所有属性都是可选的,这导致任何对象实际都符合Options类型。

为了避免这种情况,TypeScript 添加了最小可选属性规则,规定这时属于Options类型的对象,必须至少存在一个可选属性,不能所有可选属性都不存在。这就是为什么上例的myObj对象会报错的原因。

这条规则无法通过中间变量规避。

  1. const myOptions = { d: 123 };
  2. const obj:Options = myOptions; // 报错

上面示例中,即使使用了中间变量myOptions,由于存在最小可选属性规则,依然会报错。

空对象

空对象是 TypeScript 的一种特殊值,也是一种特殊类型。

  1. const obj = {};
  2. obj.prop = 123; // 报错

上面示例中,变量obj的值是一个空对象,然后对obj.prop赋值就会报错。

原因是这时 TypeScript 会推断变量obj的类型为空对象,实际执行的是下面的代码。

  1. const obj:{} = {};

空对象没有自定义属性,所以对自定义属性赋值就会报错。空对象只能使用继承的属性,即继承自原型对象Object.prototype的属性。

  1. obj.toString() // 正确

上面示例中,toString()方法是一个继承自原型对象的方法,TypeScript 允许在空对象上使用。

回到本节开始的例子,这种写法其实在 JavaScript 很常见:先声明一个空对象,然后向空对象添加属性。但是,TypeScript 不允许动态添加属性,所以对象不能分步生成,必须生成时一次性声明所有属性。

  1. // 错误
  2. const pt = {};
  3. pt.x = 3;
  4. pt.y = 4;
  5. // 正确
  6. const pt = {
  7. x: 3,
  8. y: 4
  9. };

如果确实需要分步声明,一个比较好的方法是,使用扩展运算符(...)合成一个新对象。

  1. const pt0 = {};
  2. const pt1 = { x: 3 };
  3. const pt2 = { y: 4 };
  4. const pt = {
  5. ...pt0, ...pt1, ...pt2
  6. };

上面示例中,对象pt是三个部分合成的,这样既可以分步声明,也符合 TypeScript 静态声明的要求。

空对象作为类型,其实是Object类型的简写形式。

  1. let d:{};
  2. // 等同于
  3. // let d:Object;
  4. d = {};
  5. d = { x: 1 };
  6. d = 'hello';
  7. d = 2;

上面示例中,各种类型的值(除了nullundefined)都可以赋值给空对象类型,跟Object类型的行为是一样的。

因为Object可以接受各种类型的值,而空对象是Object类型的简写,所以它不会有严格字面量检查,赋值时总是允许多余的属性,只是不能读取这些属性。

  1. interface Empty { }
  2. const b:Empty = {myProp: 1, anotherProp: 2}; // 正确
  3. b.myProp // 报错

上面示例中,变量b的类型是空对象,视同Object类型,不会有严格字面量检查,但是读取多余的属性会报错。

如果想强制使用没有任何属性的对象,可以采用下面的写法。

  1. interface WithoutProperties {
  2. [key: string]: never;
  3. }
  4. // 报错
  5. const a:WithoutProperties = { prop: 1 };

上面的示例中,[key: string]: never表示字符串属性名是不存在的,因此其他对象进行赋值时就会报错。