[TS] 認識 TypeScript 中 的型別魔術師:Mapped Type
在 TypeScript 中,Mapped Type 是一個非常有趣的東西,一開始可能會覺得有點抽象,但一旦熟悉了它的概念後,Mapped Type 就像是「型別魔法師」一樣,可以根據型別組合出各種不同的型別,也就是說如果想要能夠靈活建立 Type 的話,Mapped Type 是一個一定要會的概念。
Index Signatures Type
在了解 Mapped Type 之前,需要先來看一下它的前身 Index Signatures Type。一般來說,在 TypeScript 裡定義物件的型別會需要把物件的每一個 key 和 value 的型別都定義清楚,像是這樣:
type Person = {
firstName: string;
lastName: string;
age: number;
};
但有些時候,因為一些原因,也許是 key 的名稱不是那麼重要時,或者 key 的可能太多事,我們可以使用 index signature type 來定義這個物件,例如,定義一個 key 為 string
,value 則為 string
或 number
的型別:
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
就會依序是 apple
、samsung
和 pixel
,出來的型別會等同於:
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 單純只是可以「跑回圈」用的。
要讓 Mapped Type 發揮它強大的功力前,還需要了解另外一個重要的東西是 Keyof Type Operator
。
Keyof Type Operator
keyof
的作用其實很直觀,就是把物件的 key 給取出來:
type Person = {
firstName: string;
lastName: string;
age: number;
isMarried: boolean;
};
type PersonKeys = keyof Person;
基本上這個 PersonKeys
就會是所有 Person 物件 Key 的聯集,這裡就是 firstName | lastName | age | isMarried
:
在 TypeScript 中,object key 的型別可能是 string
、number
或 symbol
,所以如果使用 keyof any
得到的型別就會是 string | number | symbol
。
Mapped Type 搭配 keyof 的組合技
再了解 Mapped Type 和 Keyof Operator Type 之後,就可以使用這兩個的組合技。
範例一
假設現在我們定義了一系列的事件:
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;
};