
上面這個是今天會提到的內容,如果你已經可以輕鬆看懂,歡迎直接左轉去看我同事的精彩文章 — 「From State Machine to XState」!
前幾天筆者提到的 Utility Types 多半是在 TypeScript 官方文件中提到的說明,但其實在 TypeScript 中也內建了一些 Utility Types,使用者不需要額外定義這些 Utility Types 就可以直接使用,這些內建的 Utility Types 列在官方網站的 references 中,今天就來讓看其中兩個內建的 Utility Types,分別是 Exclude 和 Extract。
即使我們還不了解 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'
在知道了它們各種的用法後讓我們來看它們的實作。
讓我們先來看 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'的緣故。
讓我們把它放在一起看:

理解了 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 的原始碼:

你會發現它和 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' 的結果。
在 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
分配率好有趣! 那麼用 PJ 提到的集合概念來換句話說:
1.// 使用分配律 => ‘a’ 是 'a' | 'c' 的子集合,回傳 'a'
type ExtractReturn2 = Extract<'a' | 'b', 'a' | 'c'>; // 'a'
2.// 未使用分配律 => ‘a' | 'b' 不是 'a' | 'c' 的子集合,回傳 'never'
type NoDistributeExtractReturn2 = NoDistributeExtract<'a' | 'b', 'a' | 'c'>; // 'never'
正確?
我自己都要複習一下XD