跳至主要内容

[Day17] TS:理解 Pick、Record 的實作

Record

這是我們今天要聊的內容,老樣的,如果你已經可以輕鬆看懂,歡迎直接左轉去看同事 Andy 精彩的文章 — 「前端工程師學習 DevOps 之路」。

在學習了 Mapped Types 後,我們已經把多數要用來操作型別所需的知識補齊了,後面幾天就來看一些實際會用到的實作,包括 TypeScript 中內建以及其他第三方提供的 Utility Types。

今天讓我們先來看 PickRecord

Pick

在 TypeScript 內建的 Utility Types 中,有一個 Pick<T, K extends keyof T> ,使用方式很簡單,它可以幫選擇要保留下物件型別中的那些屬性,例如:

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

type PersonName = Pick<Person, 'firstName' | 'lastName'>;

PersonName 的型別就會等同於這樣:

type PersonName = {
firstName: string;
lastName: string;
};

在有了前面關於型別操作的知識後,會發現 Pick 的原始碼其實也蠻單純好理解的:

type Pick<T, K extends keyof T> = {
[P in K]: T[P];
};

我們可以看到 Pick 吃兩個型別參數,一個是 T 它會是物件型別,後面的 K 因為有用的 Day03 提的泛型限制,所以 K 一點要滿足是物件型別 T 中有的屬性 key。

接著 [P in K] 很明顯的是 Mapped Types,以上面提的 PersonName 為例,K 就會是 'firstName' | 'lastName',這時候的 [P in 'firstName' | 'lastName'] 讀者們應該可以預想到最後出來的型別其物件型別的屬性 key 會長這樣:

{
firstName: '...';
lastName: '...';
}

最後看到屬性 value 的部分是 T[P],意思也就是,什麼都不做,原本物件屬性值的型別是什麼就直接拿來用,因此最後 PersonName 的型別會長這樣:

type PersonName = {
firstName: string;
lastName: string;
};

Record

接著我們來看一下 Record<Keys, Type>。前面我們有提過 Mapped Types 是比較有限制的 Index Signatures,也就是 Mapped Types 是 Index Signatures 的子集合,而 Record 這個 Utility Type 同樣是基於 Mapped Types 寫出來的,先來簡單看一下它的用法。

在沒有 Record 之前

假設現在需要建立一個物件型別,它的 key 希望符合 ConferenceName、value 符合型別 Conference

type ConferenceName = 'ModernWeb' | 'MOPCON' | 'JSDOC' | '{Laravel x Vue}';
type Conference = {
name: string;
year: number;
isAddToCalendar: boolean;
website: string;
};

前面 Day14 曾提過,因為 Index Signatures 的特性,並沒有辦法直接寫:

type ConferenceIndexSignatures = {
[K: ConferenceName]: Conference;
};

TypeScript 會報錯,並建議我們用 Mapped Type:

Record

於是用 Mapped Types 可以寫成這樣:

type ConferenceMap = {
[P in ConferenceName]: Conference;
};

接著我們把它改成更泛用的形式試試看,先把 ConferenceName 抽成泛型:

// 把 ConferenceName 變成泛型
type ToConferenceMap<K> = {
[P in K]: Conference;
};

這時候你會看到 TypeScript 報錯:

Record

之所以會有這個錯誤的原因是 K 沒有辦法被保證一定能把疊代,如果 K 被帶入物件型別的話,[P in K] 就會壞掉,因此 TypeScript 說 K 應該只能是 string | number | symbol,而這其實也就是 keyof any 的意思,因此可以透過泛型限制來限制使用者可以帶入的 K

type ToConferenceMap<K extends keyof any> = {
[P in K]: Conference;
};

接著我們來把物件型別的屬性值也抽成泛型:

type ToConferenceMap<K extends keyof any, T> = {
[P in K]: T;
};

有沒有發現剛剛寫的 ToConferenceMap 變的更泛用了!現在我們可以使用剛剛自己寫的這個 Utility Type 來產生 ConferenceMap

type ConferenceName = 'ModernWeb' | 'MOPCON' | 'JSDC' | '{Laravel x Vue}';
type Conference = {
name: string;
year: number;
isAddToCalendar: boolean;
website: string;
};
type ToConferenceMap<K extends keyof any, T> = {
[P in K]: T;
};

type ConferenceMap = ToConferenceMap<ConferenceName, Conference>;

如此就可以得到我們想要的物件型別:

Record

更重要的是,其實我們已經寫出了官方提供的 Record 了!

使用 Record 與原始碼

在 TypeScript 中,Record<Keys, Type> 可以讓開發者方便定義物件型別中屬性 key 和 value 的型別,而你會發現用 Record 建立出來的型別,和用剛剛我們寫的 ToConferenceMap 的效果一樣:

// 兩個建立出來的型別是一樣的
type ConferenceMap = ToConferenceMap<ConferenceName, Conference>;
type ConferenceRecord = Record<ConferenceName, Conference>;

回過頭來看 Record 的原始碼:

// Construct a type with a set of properties K of type T
type Record<K extends keyof any, T> = {
[P in K]: T;
};

有沒有發現和我們剛剛寫的 ToConferenceMap 其實是一樣的呢?

建立自己的 Utility Types

讀者們應該慢慢可以發現,要寫出 Utility Types 比想像中的簡單,可以先根據實際的情況撰寫出想要的結果後,再把可以抽成參數的部分變成泛型,最後就可以建立出更泛用的 Utility Types。

當然也是有一些很複雜的 Utility Types 可能沒辦法這麼簡單就寫的出來,但重要的是能夠透過正確的斷句,先求能夠看懂和理解。

範例程式碼

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

參考資料