[Day09] TS:什麼!型別也有分配律?理解 Extract 和 Exclude 的實作
上面這個是今天會提到的內容,如果你已經可以輕鬆看懂,歡迎直接左轉去看我同事的精彩文章 — 「From State Machine to XState」!
前幾天筆者提到的 Utility Types 多半是在 TypeScript 官方文件中提到的說明,但其實在 TypeScript 中也內建了一些 Utility Types,使用者不需要額外定義這些 Utility Types 就可以直接使用,這些內建 的 Utility Types 列在官方網站的 references 中,今天就來讓看其中兩個內建的 Utility Types,分別是 Exclude
和 Extract
。
Extract 和 Exclude 的基本用法
即使我們還不了解 Extract
和 Exclude
是怎麼被寫出來的,但可以直接使用它,就好像有時不了解某個功能是如何被實作出來的,還是可以直接呼叫它提供的方法或函式一樣。
還記得我們前面提到了解 Utility Types 的一個小技巧就是實際帶入一個型別,把它會回傳的內容存成一個 Type Alias 來看看嗎。讓我們先來看 Extract<Type, Union>
的使用:
// https://www.typescriptlang.org/docs/handbook/utility-types.html#extracttype-union
type T1 = Extract<'a' | 'b' | 'c', 'a'>; // 'a'
type T2 = Extract<'a' | 'b' | 'c', 'a' | 'b'>; // 'a' | 'b'
type T3 = Extract<string | number | (() => void), Function>; // () => void
type T4 = Extract<'a' | 'b' | 'c', 'a' | 'f'>; // 'a'
可以看到 Extract
需要接受兩個參數 Type
和 Union
,但它會做的是把 Type
中滿足 Union
的取出,其餘不滿足的摒除掉,所以在:
- 第一個例子中,從型別
'a' | 'b' | 'c'
中留下滿足'a'
的,所以最後得到a
- 第二個例子中,從型別
'a' | 'b' | 'c'
中留下滿足'a' | 'b'
的,所以最後得到a | b
- 第三和第四個例子也是一樣的概念。
接著先來看 Exclude<Type, ExcludedUnion>
的使用:
// https://www.typescriptlang.org/docs/handbook/utility-types.html#excludetype-excludedunion
type T1 = Exclude<'a' | 'b' | 'c', 'a'>; // 'b' | 'c'
type T2 = Exclude<'a' | 'b' | 'c', 'a' | 'b'>; // 'c'
type T3 = Exclude<string | number | (() => void), Function>; // string | number
type T4 = Exclude<'a' | 'b' | 'c', 'a' | 'f'>; // 'b' | 'c
Exclude
的作用剛好和 Extract
相反,Exclude
雖然一樣需要提供兩個參數 Type
和 ExcludedUnion
,但它會做的是把 Type
中滿足 ExcludedUnion
的剔除。所以在:
- 第一個例子中,從型別
'a' | 'b' | 'c'
中剔除a
後,只會剩下'b' | 'c'
- 第二個例子中,從型別
'a' | 'b' | 'c'
中剔除'a' | 'b'
後,只會剩下'c'
- 第三和第四個例子也是一樣的概念。
在知道了它們各種的用法後讓我們來看它們的實作。
Conditional Types 的分配律(Distributive Conditional Types)
讓我們先來看 Extract
的實作:
這裡用到了我們昨天提到的 Conditional Types 的觀念,讀者應該可以理解到原始碼的意思就是:
「如果 T 是 U 的子集合,就回傳 T,否則回傳 never」
雖然我們已經理解了 Conditional Types,翻成白話文也完全正確,但在看到剛剛使用的範例是,卻好像覺得少了什麼,思路無法連貫:
type T1 = Extract<'a' | 'b' | 'c', 'a'>; // 'a'
不是說如果 'a' | 'b' | 'c'
(T)滿足 'a'
(U)的話,會直接回傳 'a' | 'b' | 'c'
(T)嗎?為什麼最後只回傳了 'a'
呢?
這裡我們就要來提一下 Conditional Types 的分配律。「分配律」這個詞有一種熟悉但有離了很遙遠的感覺,但基本上我們一定都用過,例如:
a * (b + c) = a * b + a * c
上面這個就是乘法分配律。那麼什麼是 Conditional Types 的分配律呢?
假設說我們在 Utility Type 的泛型中帶入的不是單純一個型別,而是一個 union type 時會有分配律的情況產生。舉例來說,先定義一個用 Conditional Type 寫的 Utility Type:
// 定義一個用 Conditional Type 寫的 Utility Type
type DistributeUnion<T> = T extends any ? T : never;
接著在 T
的地方帶入 union type,像是這樣:
type DistributeUnionReturn = DistributeUnion<'a' | 'b' | 'c'>; // "a" | "b" | "c"
這麼寫的意思實際上等同於:
type DistributeUnionReturn =
| DistributeUnion<'a'>
| DistributeUnion<'b'>
| DistributeUnion<'c'>;
也就是說原本的 'a' | 'b' | 'c'
會被分配到每個 DistributeUnion<T>
的 T
中在用聯集 |
起來,因為
DistributeUnion<'a'>
滿足any
,所以會直接回傳a
DistributeUnion<'b'>
滿足any
,所以會直接回傳b
DistributeUnion<'c'>
滿足any
,所以會直接回傳c
最後就會等同於:
type DistributeUnionReturn = 'a' | 'b' | 'c';
這也就是為什麼,最終的回傳值會是 'a' | 'b' | 'c'
的緣故。
讓我們把它放在一起看:
回頭來 看 Extract 和 Exclude 的原始碼
Extract 的原始碼
理解了 Distributive Conditional Types 後,再讓我們回頭看 Extract
這個 Utility Type 的實作:
現在應該可以理解,原本的翻譯「如果 T 滿足 U,就回傳 T,否則回傳 never」並沒有錯,只是要加上分配律的概念。
所以:
type T1 = Extract<'a' | 'b' | 'c', 'a'>; // 'a'
等同於:
type T1 = Extract<'a', 'a'> | Extract<'b', 'a'> | Extract<'c', 'a'>;
會變成:
type T1 = 'a' | never | never; // 'a'
而 never
就是個空集合的概念,任何東西和它取交集,還是原本的東西,因此最後就得到的 type T1 = 'a'
,是不是不會太難理解呢?
Exclude 的原始碼
接著讓我們來看 Exclude
的原始碼:
你會發現它和 Extract
最大的差別就是,Exclude
是 T
滿足 U
是會回傳 never
,而 Extract
則是會回傳 T
。
回到範例,現在讀者應該也可以理解:
type T4 = Exclude<'a' | 'b' | 'c', 'a' | 'f'>; // 'b' | 'c
等同於:
type T4 = Exclude<'a', 'a' | 'f'> | Exclude<'b', 'a' | 'f'> | Exclude<'c', 'a' | 'f'>;
會變成:
type T4 = never | 'b' | 'c';
最終就會得到 'b' | 'c'
的結果。
NonNullable
在 TypeScript 內建的 Utility Types 中還有個 NonNullable
,它可以把型別中可能存在的 null
或 undefined
都過濾掉,關於它的用法可以直接參考官網上的說明,而它的 source code 是長這樣:
// Exclude null and undefined from T
type NonNullable<T> = T extends null | undefined ? never : T;
如果讀者對於上面 Extract
和 Exclude
已經有足夠的理解,相信一定也能夠理解 NonNullable
的原始碼是如何作用以及達到預期的效果的,試著理解看看吧!
不要使用分配律
預設的情況 Conditional Types 都會使用分配律,但如果有某些使用讀者在寫自己的 Utility Type 不希望使用分配律是,可以使用在 extends
前後的型別加上中括號 []
來達成。例如,我們改寫原本的 Extract
讓它沒有分配律,也就是改成 [T]
和 [U]
,像是這樣:
type NoDistributeExtract<T, U> = [T] extends [U] ? T : never;
這時候,如果我們一樣帶入 union type,這個 Utility Type 會是完全不同的意義:
type NoDistributeExtractReturn1 = NoDistributeExtract<'a' | 'b', 'a' | 'b' | 'c'>; // 'a' | 'b'
沒有分配律的使用下會直接拿 'a' | 'b'
(T)和 'a' | 'b' | 'c'
(U)來比較,這裡因為 T
滿足 U
所以會直接回傳 T
。
同樣的,如果 T
不滿足 U
的話:
type NoDistributeExtractReturn2 = NoDistributeExtract<'a' | 'b', 'a' | 'c'>; // never
因為 'a' | 'b'
(T) 不滿足 'a' | 'c'
(U),則會回傳 never
。
範例程式碼
https://tsplay.dev/wO8YyN @ TypeScript Playground
參考資料
- Distributive Conditional Types @ TypeScript > Type Manipulation
- Utility Types @ TypeScript > Reference