跳至主要内容

[TS] 認識 TypeScript 中 的型別魔術師:Mapped Type

TypeScript Mapped Type

在 TypeScript 中,Mapped Type 是一個非常有趣的東西,一開始可能會覺得有點抽象,但一旦熟悉了它的概念後,Mapped Type 就像是「型別魔法師」一樣,可以根據型別組合出各種不同的型別,也就是說如果想要能夠靈活建立 Type 的話,Mapped Type 是一個一定要會的概念。

相關文章:Day14:什麼!TypeScript 中還有迴圈的概念 - 用 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 則為 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 單純只是可以「跑回圈」用的。

要讓 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 keyof

提示

在 TypeScript 中,object key 的型別可能是 stringnumbersymbol,所以如果使用 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;
};

寫成這樣就搞定了,未來如果 SupportedEvent 中有新增的事件類型是,HandledEvent 也不需要額外改動。

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

首先要注意到的是 [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 Typed + keyof 的例子,假設我們現在定義了一個 PersonMethod,它的 value 可能有很多不同的型別:

type PersonMethod = {
greet: (name: string) => string;
age: number;
isMarried: boolean;
name: string;
};

這時候需要定義一個 PersonMethodOptions 的型別,它的屬性名稱會和 PersonMethod 相同,但 value 的型別都是 boolean,用來表示要不要開關這個功能,這時候如果不會使用 Mapped Type 的話,我們可能會一個一個將屬性重複定出:

type PersonMethodOptions = {
greet: boolean;
age: boolean;
isMarried: boolean;
name: boolean;
};

如同前一個範例一樣,一旦 PersonMethod 中有添加新的方法,你就需要把這個方法也記得添加到 PersonMethodOptions,否則就會有不一致的情況。

但如果會使用 Mapped Type 搭配 keyof 的話,只要這樣就可以搞定了:

type PersonMethodOptions = {
[P in keyof PersonMethod]: boolean;
};

至於為什麼這樣寫就可以,可以按照前一個範例的說明,試著思考看看。

P 不只是變數

在剛剛上面的範例中,我們分別使用了:

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

type PersonMethodOptions = {
[P in keyof PersonMethod]: boolean;
};

不論是 KP 有說到它都只是個變數名稱,要取名成什麼都可以,但實際上它也不單單只是個名稱,它還可以被拿來使用,舉例來說,我們可以把它當成 : 後的型別,像是這樣:

type HandledEvent = {
// : 後面使用 K
[K in keyof SupportedEvent]: K;
};

type PersonMethodOptions = {
// : 後面使用 P
[P in keyof PersonMethod]: P;
};

至於對應出來的型別結果會是什麼,大家可以自己嘗試看看,相信會可以加深你對 Mapped Type in 這個關鍵字的理解。

進階:使用 as 把物件 key 的名稱也做轉換(Key remapping)

剛剛上面的範例中,我們大部分是保留原本物件的屬性名稱,只針對物件 value 的型別作轉換,但實際上,我們甚至也可以去轉換物件 key 的名稱。這裡會用到 TypeScript 中 Template Literal Typesas 的概念。

Template Literal Types 可以簡單想成就是 JavaScript 中的 template literal,也就是可以透過反引號(`)來在字串中帶入變數。

as 則可以讓我們在 Mapped Type 中改變物件 key 的名稱。

以上面我們用過 SupportedEvent 當作範例:

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

在 React 中,很常會使用 handleXXX 來作為方法的命名,這時候如果想要根據 SupportedEvent 的屬性 key ,但產生的是新的 keyName,變成都是以 handle 當作前綴時,除了可以自己一個一個打,像下面這樣之外:

type HandleEvent = {
handleClick: () => void;
handleChange: () => void;
handleKeyup: () => void;
handleKeydown: () => void;
};

也可以利用 Mapped Type 的觀念,這樣寫就可以了:

type HandleEvent = {
[P in keyof SupportedEvent as `handle${Capitalize<P>}`]: () => void;
};

如此也不用擔心未來 SupportedEvent 用新的 Type 加進去卻忘了補進 HandleEvent 的情況。

要理解上面的語法,一樣可以一步一步來,首先知道 P 是一個變數名稱,它可以拿到的是每次疊代時 keyof 後的內容,接著 as 的意思是我要把這個物件的 key 換一個名稱,換成的名稱是 `handle${Capitalize<P>}`

首先因為 P in keyof SupportedEvent ,所以你會知道 P 其實就是 clickchangekeyupkeydown

as 後面則是我們希望的新名稱是什麼,如果我們只是寫 `handle${P}` 的話,轉出來的 key name 會是 handleclickhandlechangehandlekeyuphandlekeydown,但因為我們希望 handle 後的第一個字母要大寫,所以這個範例中有用了 TypeScript 內建的 Capitalize 這個 type utility,它的作用是幫我們把 P 的第一個字變成大寫,於是轉出來的 key 名稱就會是我們想要的 handleClickhandleChange、...。

後記

這裡提到的只是 Mapped Type 搭配 keyof 做出的變化,Mapped Type 也很常會搭配 Indexed Access TypeCondition Types 做出更多的組合應用,這也就是為什麼 Mapped Type 可以稱作是「型別魔法師」的重要地位了。

另外,上面提到的寫法也多可以透過泛型(Generic)抽成自己的 utility type 來做重複的應用。

未來有機會的話會在來分享 Mapped Type 搭配其他組合技做出的更多應用。

範例程式碼

最後附上這篇文章中有用到的 Code 供大家參考。

參考資料

Giscus