Skip to main content

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

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 則為 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;};
tip

把 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

tip

在 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 的名稱也做轉換#

剛剛上面的範例中,我們大部分是保留原本物件的屬性名稱,只針對物件 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 供大家參考。

參考資料#