處理 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、布林值、數字、大整數、字串)的類型。有了這個類型,我們無法存取任何值的屬性。

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.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 本章來源