深入理解 TypeScript
请支持本书:购买捐款
(广告,请勿屏蔽。)

15 类型化对象



在本章中,我们将探讨如何在 TypeScript 中对对象和属性进行静态类型化。

15.1 对象扮演的角色

在 JavaScript 中,对象可以扮演两种角色(始终至少扮演其中一种角色,有时是混合角色)

首先,我们将探讨作为记录的对象。我们将在 本章后面 简要介绍作为字典的对象。

15.2 对象的类型

对象有两种不同的通用类型

对象也可以通过其属性进行类型化

// Object type literal
let obj3: {prop: boolean};

// Interface
interface ObjectType {
  prop: boolean;
}
let obj4: ObjectType;

在接下来的部分中,我们将更详细地研究所有这些对对象进行类型化的方式。

15.3 TypeScript 中的 Objectobject

15.3.1 纯 JavaScript:对象与 Object 的实例

在纯 JavaScript 中,有一个重要的区别。

一方面,大多数对象都是 Object 的实例。

> const obj1 = {};
> obj1 instanceof Object
true

这意味着

另一方面,我们也可以创建原型链中没有 Object.prototype 的对象。例如,以下对象根本没有任何原型

> const obj2 = Object.create(null);
> Object.getPrototypeOf(obj2)
null

obj2 是一个不是类 Object 实例的对象

> typeof obj2
'object'
> obj2 instanceof Object
false

15.3.2 TypeScript 中的 Object(大写字母“O”):类 Object 的实例

回想一下,每个类 C 都会创建两个实体

类似地,TypeScript 有两个内置接口

这些是接口

interface Object { // (A)
  constructor: Function;
  toString(): string;
  toLocaleString(): string;
  valueOf(): Object;
  hasOwnProperty(v: PropertyKey): boolean;
  isPrototypeOf(v: Object): boolean;
  propertyIsEnumerable(v: PropertyKey): boolean;
}

interface ObjectConstructor {
  /** Invocation via `new` */
  new(value?: any): Object;
  /** Invocation via function calls */
  (value?: any): any;

  readonly prototype: Object; // (B)

  getPrototypeOf(o: any): any;

  // ···
}
declare var Object: ObjectConstructor; // (C)

观察结果

15.3.3 TypeScript 中的 object(小写字母“o”):非原始值

在 TypeScript 中,object 是所有非原始值的类型(原始值是 undefinednull、布尔值、数字、bigint、字符串)。使用此类型,我们无法访问值的任何属性。

15.3.4 Objectobject:原始值

有趣的是,类型 Object 也匹配原始值

function func1(x: Object) { }
func1('abc'); // OK

为什么?原始值具有 Object 所需的所有属性,因为它们继承了 Object.prototype

> 'abc'.hasOwnProperty === Object.prototype.hasOwnProperty
true

相反,object 不匹配原始值

function func2(x: object) { }
// @ts-expect-error: Argument of type '"abc"' is not assignable to
// parameter of type 'object'. (2345)
func2('abc');

15.3.5 Objectobject:不兼容的属性类型

对于类型 Object,如果对象的属性类型与其在接口 Object 中的对应属性冲突,TypeScript 会发出警告

// @ts-expect-error: Type '() => number' is not assignable to
// type '() => string'.
//   Type 'number' is not assignable to type 'string'. (2322)
const obj1: Object = { toString() { return 123 } };

对于类型 object,TypeScript 不会发出警告(因为 object 没有指定任何属性,并且不会有任何冲突)

const obj2: object = { toString() { return 123 } };

15.4 对象类型字面量和接口

TypeScript 有两种定义对象类型的方式,它们非常相似

// Object type literal
type ObjType1 = {
  a: boolean,
  b: number;
  c: string,
};

// Interface
interface ObjType2 {
  a: boolean,
  b: number;
  c: string,
}

我们可以使用分号或逗号作为分隔符。允许使用尾随分隔符,并且是可选的。

15.4.1 对象类型字面量和接口之间的区别

在本节中,我们将了解对象类型字面量和接口之间最重要的区别。

15.4.1.1 内联

对象类型字面量可以内联,而接口不能

// Inlined object type literal:
function f1(x: {prop: number}) {}

// Referenced interface:
function f2(x: ObjectInterface) {} 
interface ObjectInterface {
  prop: number;
}
15.4.1.2 重复名称

具有重复名称的类型别名是非法的

// @ts-expect-error: Duplicate identifier 'PersonAlias'. (2300)
type PersonAlias = {first: string};
// @ts-expect-error: Duplicate identifier 'PersonAlias'. (2300)
type PersonAlias = {last: string};

相反,具有重复名称的接口会被合并

interface PersonInterface {
  first: string;
}
interface PersonInterface {
  last: string;
}
const jane: PersonInterface = {
  first: 'Jane',
  last: 'Doe',
};
15.4.1.3 映射类型

对于映射类型(A 行),我们需要使用对象类型字面量

interface Point {
  x: number;
  y: number;
}

type PointCopy1 = {
  [Key in keyof Point]: Point[Key]; // (A)
};

// Syntax error:
// interface PointCopy2 {
//   [Key in keyof Point]: Point[Key];
// };

  有关映射类型的更多信息

映射类型超出了本书当前的范围。有关更多信息,请参阅 TypeScript 手册

15.4.1.4 多态 this 类型

多态 this 类型只能在接口中使用

interface AddsStrings {
  add(str: string): this;
};

class StringBuilder implements AddsStrings {
  result = '';
  add(str: string) {
    this.result += str;
    return this;
  }
}

  本节来源

  从现在开始,“接口”表示“接口或对象类型字面量”(除非另有说明)。

15.4.2 接口在 TypeScript 中是结构化的

接口是结构化的——它们不必为了匹配而实现

interface Point {
  x: number;
  y: number;
}
const point: Point = {x: 1, y: 2}; // OK

有关此主题的更多信息,请参阅 [未包含的内容]

15.4.3 接口和对象类型字面量的成员

接口和对象类型字面量主体内的构造称为它们的成员。以下是它们最常见的成员

interface ExampleInterface {
  // Property signature
  myProperty: boolean;

  // Method signature
  myMethod(str: string): number;

  // Index signature
  [key: string]: any;

  // Call signature
  (num: number): string;

  // Construct signature
  new(str: string): ExampleInstance; 
}
interface ExampleInstance {}

让我们更详细地了解这些成员

属性签名应该是自解释的。 调用签名构造签名 将在本书后面介绍。接下来,我们将仔细研究方法签名和索引签名。

15.4.4 方法签名

就 TypeScript 的类型系统而言,方法定义和值为函数的属性是等效的

interface HasMethodDef {
  simpleMethod(flag: boolean): void;
}
interface HasFuncProp {
  simpleMethod: (flag: boolean) => void;
}

const objWithMethod: HasMethodDef = {
  simpleMethod(flag: boolean): void {},
};
const objWithMethod2: HasFuncProp = objWithMethod;

const objWithOrdinaryFunction: HasMethodDef = {
  simpleMethod: function (flag: boolean): void {},
};
const objWithOrdinaryFunction2: HasFuncProp = objWithOrdinaryFunction;

const objWithArrowFunction: HasMethodDef = {
  simpleMethod: (flag: boolean): void => {},
};
const objWithArrowFunction2: HasFuncProp = objWithArrowFunction;

我的建议是使用最能表达属性应如何设置的语法。

15.4.5 索引签名:作为字典的对象

到目前为止,我们只将接口用于具有固定键的作为记录的对象。我们如何表达一个对象将被用作字典的事实?例如:在以下代码片段中,TranslationDict 应该是什么?

function translate(dict: TranslationDict, english: string): string {
  return dict[english];
}

我们使用索引签名(A 行)来表示 TranslationDict 用于将字符串键映射到字符串值的对象

interface TranslationDict {
  [key:string]: string; // (A)
}
const dict = {
  'yes': 'sí',
  'no': 'no',
  'maybe': 'tal vez',
};
assert.equal(
  translate(dict, 'maybe'),
  'tal vez');
15.4.5.1 对索引签名键进行类型化

索引签名键必须是 stringnumber

15.4.5.2 字符串键与数字键

就像在纯 JavaScript 中一样,TypeScript 的数字属性键是字符串属性键的子集(请参阅“面向不耐烦程序员的 JavaScript”)。因此,如果我们同时具有字符串索引签名和数字索引签名,则前者的属性类型必须是后者的超类型。以下示例有效,因为 ObjectRegExp 的超类型

interface StringAndNumberKeys {
  [key: string]: Object;
  [key: number]: RegExp;
}

// %inferred-type: (x: StringAndNumberKeys) =>
// { str: Object; num: RegExp; }
function f(x: StringAndNumberKeys) {
  return { str: x['abc'], num: x[123] };
}
15.4.5.3 索引签名与属性签名和方法签名

如果在一个接口中同时存在索引签名和属性和/或方法签名,则索引属性值的类型也必须是属性值和/或方法类型的超类型。

interface I1 {
  [key: string]: boolean;

  // @ts-expect-error: Property 'myProp' of type 'number' is not assignable
  // to string index type 'boolean'. (2411)
  myProp: number;
  
  // @ts-expect-error: Property 'myMethod' of type '() => string' is not
  // assignable to string index type 'boolean'. (2411)
  myMethod(): string;
}

相反,以下两个接口不会产生错误

interface I2 {
  [key: string]: number;
  myProp: number;
}

interface I3 {
  [key: string]: () => string;
  myMethod(): string;
}

15.4.6 接口描述 Object 的实例

所有接口都描述了 Object 的实例并继承了 Object.prototype 属性的对象。

在以下示例中,类型为 {} 的参数 x 与返回类型 Object 兼容

function f1(x: {}): Object {
  return x;
}

类似地,{} 具有方法 .toString()

function f2(x: {}): { toString(): string } {
  return x;
}

15.4.7 多余属性检查:何时允许存在多余属性?

例如,请考虑以下接口

interface Point {
  x: number;
  y: number;
}

可以通过两种方式(以及其他方式)来解释此接口

TypeScript 同时使用这两种解释。为了探讨它是如何工作的,我们将使用以下函数

function computeDistance(point: Point) { /*...*/ }

默认情况下,允许存在多余属性 .z

const obj = { x: 1, y: 2, z: 3 };
computeDistance(obj); // OK

但是,如果我们直接使用对象字面量,则禁止存在多余属性

// @ts-expect-error: Argument of type '{ x: number; y: number; z: number; }'
// is not assignable to parameter of type 'Point'.
//   Object literal may only specify known properties, and 'z' does not
//   exist in type 'Point'. (2345)
computeDistance({ x: 1, y: 2, z: 3 }); // error

computeDistance({x: 1, y: 2}); // OK
15.4.7.1 为什么对象字面量中禁止存在多余属性?

为什么对对象字面量有更严格的规则?它们提供了针对属性键中拼写错误的保护。我们将使用以下接口来演示这意味着什么。

interface Person {
  first: string;
  middle?: string;
  last: string;
}
function computeFullName(person: Person) { /*...*/ }

属性 .middle 是可选的,可以省略(可选属性将在 本章后面 介绍)。对于 TypeScript 来说,将其名称拼写错误看起来像是省略了它并提供了一个多余属性。但是,它仍然会捕获拼写错误,因为在这种情况下不允许存在多余属性

// @ts-expect-error: Argument of type '{ first: string; mdidle: string;
// last: string; }' is not assignable to parameter of type 'Person'.
//   Object literal may only specify known properties, but 'mdidle'
//   does not exist in type 'Person'. Did you mean to write 'middle'?
computeFullName({first: 'Jane', mdidle: 'Cecily', last: 'Doe'});
15.4.7.2 为什么如果对象来自其他地方,则允许存在多余属性?

其想法是,如果对象来自其他地方,我们可以假设它已经被审查过,并且不会有任何拼写错误。那么我们可以不用那么小心。

如果拼写错误不是问题,那么我们的目标应该是最大限度地提高灵活性。请考虑以下函数

interface HasYear {
  year: number;
}

function getAge(obj: HasYear) {
  const yearNow = new Date().getFullYear();
  return yearNow - obj.year;
}

如果不允许传递给 getAge() 的大多数值存在多余属性,那么此函数的用处将非常有限。

15.4.7.3 空接口允许存在多余属性

如果一个接口是空的(或者使用了对象类型字面量 {}),则始终允许额外的属性。

interface Empty { }
interface OneProp {
  myProp: number;
}

// @ts-expect-error: Type '{ myProp: number; anotherProp: number; }' is not
// assignable to type 'OneProp'.
//   Object literal may only specify known properties, and
//   'anotherProp' does not exist in type 'OneProp'. (2322)
const a: OneProp = { myProp: 1, anotherProp: 2 };
const b: Empty = {myProp: 1, anotherProp: 2}; // OK
15.4.7.4 仅匹配没有属性的对象

如果我们想强制一个对象没有属性,可以使用以下技巧(感谢:Geoff Goodman

interface WithoutProperties {
  [key: string]: never;
}

// @ts-expect-error: Type 'number' is not assignable to type 'never'. (2322)
const a: WithoutProperties = { prop: 1 };
const b: WithoutProperties = {}; // OK
15.4.7.5 在对象字面量中允许额外的属性

如果我们想在对象字面量中允许额外的属性怎么办?例如,考虑接口 Point 和函数 computeDistance1()

interface Point {
  x: number;
  y: number;
}

function computeDistance1(point: Point) { /*...*/ }

// @ts-expect-error: Argument of type '{ x: number; y: number; z: number; }'
// is not assignable to parameter of type 'Point'.
//   Object literal may only specify known properties, and 'z' does not
//   exist in type 'Point'. (2345)
computeDistance1({ x: 1, y: 2, z: 3 });

一种选择是将对象字面量赋值给一个中间变量。

const obj = { x: 1, y: 2, z: 3 };
computeDistance1(obj);

第二种选择是使用类型断言。

computeDistance1({ x: 1, y: 2, z: 3 } as Point); // OK

第三种选择是重写 computeDistance1(),使其使用类型参数。

function computeDistance2<P extends Point>(point: P) { /*...*/ }
computeDistance2({ x: 1, y: 2, z: 3 }); // OK

第四种选择是扩展接口 Point,使其允许额外的属性。

interface PointEtc extends Point {
  [key: string]: any;
}
function computeDistance3(point: PointEtc) { /*...*/ }

computeDistance3({ x: 1, y: 2, z: 3 }); // OK

我们将继续讨论两个 TypeScript 不允许额外属性的例子。

15.4.7.5.1 允许额外的属性:示例 Incrementor

在这个例子中,我们想实现一个 Incrementor,但 TypeScript 不允许额外的属性 .counter

interface Incrementor {
  inc(): void
}
function createIncrementor(start = 0): Incrementor {
  return {
    // @ts-expect-error: Type '{ counter: number; inc(): void; }' is not
    // assignable to type 'Incrementor'.
    //   Object literal may only specify known properties, and
    //   'counter' does not exist in type 'Incrementor'. (2322)
    counter: start,
    inc() {
      // @ts-expect-error: Property 'counter' does not exist on type
      // 'Incrementor'. (2339)
      this.counter++;
    },
  };
}

唉,即使使用类型断言,仍然有一个类型错误。

function createIncrementor2(start = 0): Incrementor {
  return {
    counter: start,
    inc() {
      // @ts-expect-error: Property 'counter' does not exist on type
      // 'Incrementor'. (2339)
      this.counter++;
    },
  } as Incrementor;
}

我们可以向接口 Incrementor 添加索引签名。或者 - 特别是在不可能的情况下 - 我们可以引入一个中间变量。

function createIncrementor3(start = 0): Incrementor {
  const incrementor = {
    counter: start,
    inc() {
      this.counter++;
    },
  };
  return incrementor;
}
15.4.7.5.2 允许额外的属性:示例 .dateStr

以下比较函数可用于对具有属性 .dateStr 的对象进行排序。

function compareDateStrings(
  a: {dateStr: string}, b: {dateStr: string}) {
    if (a.dateStr < b.dateStr) {
      return +1;
    } else if (a.dateStr > b.dateStr) {
      return -1;
    } else {
      return 0;
    }
  }

例如,在单元测试中,我们可能希望直接使用对象字面量调用此函数。TypeScript 不允许我们这样做,我们需要使用一种解决方法。

15.5 类型推断

这些是 TypeScript 为通过各种方式创建的对象推断出的类型。

// %inferred-type: Object
const obj1 = new Object();

// %inferred-type: any
const obj2 = Object.create(null);

// %inferred-type: {}
const obj3 = {};

// %inferred-type: { prop: number; }
const obj4 = {prop: 123};

// %inferred-type: object
const obj5 = Reflect.getPrototypeOf({});

原则上,Object.create() 的返回类型可以是 object。但是,any 允许我们添加和更改结果的属性。

15.6 接口的其他特性

15.6.1 可选属性

如果我们在属性名称后面加上一个问号 (?),则该属性是可选的。相同的语法用于将函数、方法和构造函数的参数标记为可选。在以下示例中,属性 .middle 是可选的。

interface Name {
  first: string;
  middle?: string;
  last: string;
}

因此,可以省略该属性(A 行)。

const john: Name = {first: 'Doe', last: 'Doe'}; // (A)
const jane: Name = {first: 'Jane', middle: 'Cecily', last: 'Doe'};
15.6.1.1 可选与 undefined|string

.prop1.prop2 之间有什么区别?

interface Interf {
  prop1?: string;
  prop2: undefined | string; 
}

可选属性可以做 undefined|string 可以做的所有事情。我们甚至可以对前者使用值 undefined

const obj1: Interf = { prop1: undefined, prop2: undefined };

但是,只有 .prop1 可以省略。

const obj2: Interf = { prop2: undefined };

// @ts-expect-error: Property 'prop2' is missing in type '{}' but required
// in type 'Interf'. (2741)
const obj3: Interf = { };

如果我们想明确表示省略,则 undefined|stringnull|string 等类型很有用。当人们看到这样一个被明确省略的属性时,他们知道它存在,但被关闭了。

15.6.2 只读属性

在以下示例中,属性 .prop 是只读的。

interface MyInterface {
  readonly prop: number;
}

因此,我们可以读取它,但不能更改它。

const obj: MyInterface = {
  prop: 1,
};

console.log(obj.prop); // OK

// @ts-expect-error: Cannot assign to 'prop' because it is a read-only
// property. (2540)
obj.prop = 2;

15.7 JavaScript 的原型链和 TypeScript 的类型

TypeScript 不区分自身属性和继承属性。它们都被简单地视为属性。

interface MyInterface {
  toString(): string; // inherited property
  prop: number; // own property
}
const obj: MyInterface = { // OK
  prop: 123,
};

objObject.prototype 继承 .toString()

这种方法的缺点是 JavaScript 中的某些现象无法通过 TypeScript 的类型系统来描述。优点是类型系统更简单。

15.8 本章资料来源