TypeScript - Advanced Object Types

學習資源:

Codecademy

Introduction

在寫 TypeScript 一個常見的挑戰是如何在程式碼會遇到的狀況下使用型別,比如下面的例子:

class Robot {
  identify(id: number) {
    console.log(`beep! I'm ${id}`);
  }
}

在上面宣告一個 class 叫做 Robot,但要怎麼去使用它?並且,一些機器人可能比其他機器人有更多的功能,或者具有變化的屬性名稱。在這些情況下,要如何去應用型別?

所以這篇文章會著重在如何把型別應用在這些狀況上。並且也會深入了解如何把型別應用在物件導向上。

Interfaces and Types

在 TypeScript 除了可以應用 type 來定義型別,可以使用 interfaces。

使用 type 如下:

type Mail = {
  postagePrice: number;
  address: string;
}

const catalog: Mail = ...

使用 interfaces 如下:

interface Mail {
  postagePrice: number;
  address: string;
}

const catalog: Mail = ...

以上兩者除了語法上有些不同,在功能上其實一樣。

而最大的不同是,interfaces 只能用來做 object 的型別註釋,而 type 則可以使用在 object、primitives 等等的型別。目前看來 type 感覺比 interfaces 實用很多,那為什麼要使用 interfaces 呢?

理由是這樣子的,有時,我們不想要一個能做所有事情的型別。會希望我們的型別受到某些限制,這樣的限制可以讓我們寫一個風格比較一致的程式。而由於 interfaces 只能註釋 object,因此它非常適合編寫物件導向的程式。

Interfaces and Classes

TypeScript 常使用 interfacesclass 來做搭配使用。讓我們能夠使用 implements 將型別應用於 object / class 上,例如:

interface Robot {
  identify: (id: number) => void;
}

class OneSeries implements Robot {
  identify(id: number) {
    console.log(`beep! I'm ${id.toFixed(2)}.`);
  }

  answerQuestion() {
    console.log('42!');
  }
}

以上的例子有一些細節要注意。因為 Robot 自己有 .identify() 的方法,而 Robot 應用於 OneSeries,所以 OneSeries 一定要有 .identify() 的方法。而 OneSeries 也可以有自己的方法 answerQuestion()

藉由使用 interfacesclass 可以讓型別在應用上有更多彈性,並且很好地實現物件導向的方式。

Deep Types

有時當程式碼愈來愈複雜的時候,會需要新增更多的方法與屬性到object 中,以至於讓我們會讓這個巢狀結構愈來愈深:

class OneSeries implements Robot {
  about;

  constructor(props: { general: { id: number; name: string; } }) {
    this.about = props;
  }

  getRobotId() {
    return `ID: ${this.about.general.id}`;
  }
}

在這個 class 中,OneSeries 期望有一個在 object 中的 object 的屬性 about 。而在 getRobotId() 中,OneSeries 會返回 this.about.general.id,所以為了註釋在 object 中的 object 型別,我們可以使用像以下的 interfaces :

interface Robot {
  about: {
    general: {
      id: number;
      name: string;
    };
  };
  getRobotId: () => string;
}

Composed Types

隨著資料的巢狀結構越來越深,會導致 object 變得難以寫入和讀取。比如以下型別:

interface About {
  general: {
    id: number;
    name: string;
    version: {
      versionNumber: number;
    }
  }
}

這種型別有兩個層的巢狀結構。這可能適用於一個簡短的程式,但隨著我們程式的擴充套件和我們需要的功能變多,我們可能會遇到兩個問題:

  1. 隨著我們新增更多資料,這個 interface 可能會有更多巢狀結構,以至於我們自己和其他開發人員很難閱讀。
  2. 有可能在使用的時候,只想要這種型別的部分 object。比如,可能只想要version 這個 object,而不想要其他部分。

為了解決這個問題,TypeScript 可以讓我們組合型別。可以藉由定義多種型別,並在其他型別中引用它們。比如:

interface About {
  general: General;
}

interface General {
  id: number;
  name: string;
  version: Version;
}

interface Version {
  versionNumber: number;
}

雖然產生的程式稍長一些,但我們現在可以更輕鬆地讀取程式,並且可以在程式中的其他地方重複使用較小的 interface

Extending Interfaces

在 TypeScript中,組合型別的方式不一定總是足夠的。有時,如果能將所有型別成員從一個型別複製到另一個型別就會很方便。所以這時可以使用extension 來完成,比如:

interface Shape {
  color: string;
}

interface Square extends Shape {
  sideLength: number;
}

const mySquare: Square = { sideLength: 10, color: 'blue' };

使用 extension 可以幫助我們管理程式,方法是將提取共同型別成員到他們自己的 interface 中,然後將它們複製到更特別的型別。

Index Signatures

有時在再輸入 object 型別的資料時,並不會知道 object 裡的屬性名稱。比如從 API 獲取資料時。而雖然不知道屬性名稱是什麼,但屬性是什麼型別以及 value 是什麼型別是可以預先知道的。因此在為 object 做型別註釋的時候,可以用一個變數先來代表屬性的名字,這樣的方式就叫 Index Signatures。

比如說,我們查詢地圖API,獲得可以檢視日食的緯度列表。資料可能看起來像:

{
  '40.712776': true;
  '41.203323': true;
  '40.417286': false;
}

而如同上面所示,屬性名稱會是 string, value 會是 booleans,儘管我們不知道那時實際的屬性名字會是什麼。為了幫這樣的 object 標記型別,用 Index Signatures 來做的話:

interface SolarEclipse {
  [latitude: string]: boolean;
}

這樣的方式不僅可以通用,閱讀性上也比較好。

Optional Type Members

interface 所標的物件型別中的成員不是每個都是必須要提供的,其實也可以是有選擇的。比如以下:

interface OptionsType {
  name: string;
  size?: string;
}

function listFile(options: OptionsType) {
  let fileName = options.name;

  if (options.size) {
    fileName = `${fileName}: ${options.size}`;
  }

  return fileName;
}

就像以上的 size? 所標示的,表示 size 是可以不提供的。也就是說像下面在呼叫 listFile() 時,可以不提供關於 size 的資訊。

listFile({ name: 'readme.txt' })