跳至主要内容

[Day14] TS:什麼!TypeScript 中還有迴圈的概念 - 用 Mapped Type 操作物件型別

Mapped Type)

上面這個是今天會提到的內容,如果你已經可以輕鬆看懂,歡迎直接左轉去看我隊友們的精彩文章!

Index Signatures & Indexable Types

在了解 Mapped Type 之前,需要先來看一下它的前身 Index Signatures。一般來說,在 TypeScript 裡定義物件的型別會需要把物件的每一個 key 和 value 的型別都定義清楚,像是這樣:

type Person = {
firstName: string;
lastName: string;
age: number;
};

但有些時候,因為一些原因,也許是 key 的名稱不是那麼重要時,或者 key 的可能太多時,我們可以使用 index signatures 來定義這個物件,例如,定義一個 key 為 string,value 則為 stringnumber 的型別:

type PersonDict = {
// "key" 可以是取成任何名稱
[key: string]: string | number;
};

[key: string] 中的這個 key 可以是任何名稱,你也可以改成 [property: string] 效果是一樣的。

Mapped Type 和 in operator

在了解 index signatures type 後,就可以來初步認識 Mapped Type。而在 Mapped Type 中最重要的就是 in 這個關鍵字的使用。

先來看看 in 怎麼用:

type PersonMap = {
[key in 'firstName' | 'lastName']: string;
};

這裡和 index signatures 類似,你一樣會看到像是 [key: ... ] 這樣的寫法,key 一樣是可以自己取的變數名稱,而不一樣的是多了 in 這個關鍵字。

這個 in 的感覺非常「類似」在 JavaScript 中 Array 的 for...of 方法,上面的 [key in 'firstName' | 'lastName'] 可以想成是這樣的感覺:

// Mapped Type 中的 [key in 'firstName' | 'lastName'],很類似於 for...of 的方法
for (const key of ['firstName', 'lastName']) {
console.log(key);
}

for (const key of ...) 中,這個 key 會是每次疊代時陣列取得的元素值,所以這裡的話第一次會是 firstName,第二次會是 lastName

回到 Mapped Type 中的 [key in 'firstName' | 'lastName'],這裡的 key 也是很類似的概念,可以想像成會跑一個迴圈,第一次 key 的值會是 firstName,第二次的值則是 lastName

也就是說,上面的:

type PersonMap = {
[key in 'firstName' | 'lastName']: string;
};

其實就是:

type PersonMap = {
firstName: string;
lastName: string;
};
提示

把 Mapped Type 中的 key in ... 想成是類似 for (const key of ...) 的概念,是我認為理解 mapped type 最重要的一步。它都有跑一個迴圈把所有元素依序取出來的概念。

由於 key 只是一個變數名稱,它也可以命名成其他名稱,又因為它表示的是物件屬性(property),所以也很常會用 P 來表示它,例如下面建立另一個 mapped type:

type Device = {
[P in 'apple' | 'samsung' | 'google']: string;
};

如果了解剛剛的說明的話,應該可以想到,這裡 P 就會依序是 applesamsungpixel,出來的型別會等同於:

type Device = {
apple: string;
samsung: string;
pixel: string;
};

這裡你也可以理解到 mapped type 和 indexed signature 的差別,mapped type 可以視為是 indexed signatures 的子集合(subset),它能將物件的屬性定義的更明確,而不是單純用某個型別來表示:

// index signatures:物件的屬性只要是 string 即可
type DeviceDict = {
[key: string]: string;
};

// mapped type:物件的屬性需要是 'apple' | 'samsung' | 'google'
type DeviceMap = {
[P in 'apple' | 'samsung' | 'google']: string;
};

單純看到這裡,可能還是感受不到為什麼會說 Mapped Type 是可以用來操作型別的「型別魔術師」,只會覺得 Mapped Type 單純只是可以「跑迴圈」用的。

搭配 keyof 修改 Object Type 中所有 value 的型別

讓我們回到今天的例子。

假設現在我們定義了一系列的事件:

type SupportedEvent = {
click: string;
change: string;
keyup: string;
keydown: string;
};

這時候如果想根據 SupportedEvent 中屬性的名稱,產生一個新的型別叫做 HandledEvent,但物件 value 的型別要全部換成 function 的話,我們當然可以如同過去一個一個把屬性定義出來:

type HandledEvent = {
click: () => void;
change: () => void;
keyup: () => void;
keydown: () => void;
};

但這麼做除了很麻煩之外,未來如果有新增支援的事件類型到 SupportedEvent 的話,還需要同時記得加到 HandledEvent 這個型別中,如果忘記加的話,兩個型別中支援的 Event 類型就會不一致。

這時候使用 Mapped Type 搭配 keyof 就會非常的方便:

type HandledEvent = {
[K in keyof SupportedEvent]: () => void;
};

寫成這樣就搞定了,未來如果 SupportedEvent 中有新增的事件類型是,HandledEvent 也不需要額外改動,如此將符合 Single Source of Truth 的概念。

現在來了解一下我們剛剛的組合技是怎麼使用的。

首先要注意到的是 [K in keyof SupportedEvent]

  • 前面有提到 K 只是一個變數名稱,會對應到的是 in 後面每次取出來的值。

接著把注意力放到 in 後面的內容,它是 keyof SupportedEvent

  • 搭配前面對於 keyof 的理解,可以知道 keyof SupportedEvent,對應到也就會是 click | change | keyup | keydown

也就是說如果換成 JavaScript for ... of 的方法,它會像是這樣:

//  [K in keyof SupportedEvent]
for (const K of ['click', 'change', 'keyup', 'keydown']) {
/* ... */
}

這樣你就可以知道,K 其實對應到的就會是 clickchangekeyupkeydown

再來是 [K in keyof SupportedEvent]: () => void;: 後的內容就會是物件 value 的型別。因為我們想要把它改成 function,所以就在後面放了 () => void

Mapped Type 學習重點

  • 把 Mapped Type 中的 [KEY in ...] 想成是類似 for (const key of ...) 的概念,是我認為理解 mapped type 最重要的一步。它們都有跑一個迴圈把所有元素依序取出來的概念。
  • 操作物件型別時,要很快聯想到 Mapped Types,不論是想改變物件屬性 key 的名稱或 value 的型別,都可以透過。Mapped Types 達到。

範例程式碼

https://tsplay.dev/mx5v8W @ TypeScript Playground

參考資料