跳至主要内容

[Day09] TS:什麼!型別也有分配律?理解 Extract 和 Exclude 的實作

Distributive Conditional Types

上面這個是今天會提到的內容,如果你已經可以輕鬆看懂,歡迎直接左轉去看我同事的精彩文章 — 「From State Machine to XState」!

前幾天筆者提到的 Utility Types 多半是在 TypeScript 官方文件中提到的說明,但其實在 TypeScript 中也內建了一些 Utility Types,使用者不需要額外定義這些 Utility Types 就可以直接使用,這些內建的 Utility Types 列在官方網站的 references 中,今天就來讓看其中兩個內建的 Utility Types,分別是 ExcludeExtract

Extract 和 Exclude 的基本用法

即使我們還不了解 ExtractExclude 是怎麼被寫出來的,但可以直接使用它,就好像有時不了解某個功能是如何被實作出來的,還是可以直接呼叫它提供的方法或函式一樣。

還記得我們前面提到了解 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 需要接受兩個參數 TypeUnion,但它會做的是把 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 雖然一樣需要提供兩個參數 TypeExcludedUnion,但它會做的是把 Type 中滿足 ExcludedUnion 的剔除。所以在:

  • 第一個例子中,從型別 'a' | 'b' | 'c' 中剔除 a 後,只會剩下 'b' | 'c'
  • 第二個例子中,從型別 'a' | 'b' | 'c' 中剔除 'a' | 'b' 後,只會剩下 'c'
  • 第三和第四個例子也是一樣的概念。

在知道了它們各種的用法後讓我們來看它們的實作。

Conditional Types 的分配律(Distributive Conditional Types)

讓我們先來看 Extract 的實作:

Distributive Conditional Types

這裡用到了我們昨天提到的 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 和 Exclude 的原始碼

Extract 的原始碼

理解了 Distributive Conditional Types 後,再讓我們回頭看 Extract 這個 Utility Type 的實作:

Distributive Conditional Types

現在應該可以理解,原本的翻譯「如果 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 的原始碼:

Exclude

你會發現它和 Extract 最大的差別就是,ExcludeT 滿足 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,它可以把型別中可能存在的 nullundefined 都過濾掉,關於它的用法可以直接參考官網上的說明,而它的 source code 是長這樣:

// Exclude null and undefined from T
type NonNullable<T> = T extends null | undefined ? never : T;

如果讀者對於上面 ExtractExclude 已經有足夠的理解,相信一定也能夠理解 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

參考資料