banner
沈青川

旧巷馆子

愿我如长风,渡君行万里。
twitter
jike

🤸🏻 記一次類型體操與其實戰應用

Avatar

本文是一篇引導探索式的學習筆記,希望你可以耐心、細心地跟著文章內容的節奏一點點理解,相信你會有一些收穫!

剛開始只是想學習一下 TS 的一道常見題:Union to Tuple,把 "a" | "b" 這樣的聯合類型(Union type)轉換為相應的 ["a", "b"] 這樣一個元組(Tuple),這個題其實綜合性還蠻強的,我們來看看怎麼做。

熟悉使用 infer 作為工具來做操的同學可能第一時間會很自然地想到如下這種方式做一個條件類型推斷(conditional type infer):

type UnionToTuple<Union> = Union extends infer A | infer B ? [A, B] : never;
type IncorrectTuple = UnionToTuple<"a" | "b">;

結果並非如我們所想,這個 IncorrectTuple 的結果是 ["a", "a"] | ["b" | "b"],這是因為在 TS 中 “對裸類型參數做條件類型推算” 被設計為 “有分配性”(distributive)的。即如果 extends 左邊是聯合類型,那麼 TS 會把 extends 操作符左邊的聯合類型拆開做判斷,所以實際上是在做:'a' extends ...b extends ...,因此看來這個思路是行不通的。

想解這個題還得用到一些別的 “技巧性變換”、或者你也可以說是利用某些 TS 的特性。這些技巧屬於 “知識性內容”,不是那種用你已經學過的知識推導出來的東西,所以想要增長自己的 “體操能力”,只能靠閱覽和吸收過大量的例子,用足夠的經驗沉澱出來。

細細想一下我們要做到的事就不難發現,既然是要用 Union 類型中的每一個子項構造元組,第一步就是要能把上面例子裡的 "a""b" 取出來。

先來看下面這個例 1:

type U1_1 = (a: number) => void
type U1_2 = (a: boolean) => void

type U1 = ((arg: U1_1) => void) | ((arg: U1_2) => void)

type TEST = U1 extends (arg: infer I) => void ? I : never

// TEST: ((a: number) => void) & ((a: boolean) => void)

我們驚喜地發現,在對一個聯合類型作條件類型推算後竟然可以得到一個交叉類型!這個計算是什麼意思呢?我們來解讀一下:

extends 左邊的這個聯合類型意思是:這是一個函數、它參數也是一個函數、而且單參,這個參數 arg 可能為 number 、也可能是 boolean

而根據 TypeScript 的函數參數類型具有「 逆變 」這個特質 我們可以得出:arg 必須得是 U1_1U1_2 作交叉類型,即 U1_1 & U1_2。若你不清楚這個結論的緣由,請認真閱讀完這個鏈接裡的文章理清協變和逆變的概念。

我們可以編寫一個幫手類型來把 "a" | "b" 構造成 ((arg: (a: "a") => void) => void) | ((arg: (a: "b") => void) => void) ,然後再將它轉換為交叉類型:

type UnionToFnUnions<U> = U extends any
	// 因為幾乎所有類型都 extends any,
	// 所以下面這兒我們可以帶著 U 進行任意構造
	? (k: (x: U) => void) => void
	: never

type UnionToFnIntersections<U> = UnionToFnUnions<U> extends (arg: infer I) => void ? I : never

我們再來看例 2,了解一個 TS 的特性:

type T1 = (
  ((arg1: number) => void) 
  & ((arg1: boolean) => void)
)

interface T2 {
  (arg1: number): void
  (arg1: boolean): void
}

type T3 = T1 extends T2 ? true : false
//   ^? true

我們得到這樣一個結論:在 TS 中,多個簽名不同的函數類型作交叉類型,等價於有多個重載的函數接口類型。而在這種接口類型上可以作如下的 infer 推斷:

type FnIntersectionToTuple<T> = T extends {
    (x: infer A): void;
    (x: infer B): void;
} ? [A, B] : never;

終於看到眉目了!我們總算找到了一條崎嶇蜿蜒的路拿到最終期望的結果。但是這似乎並不太完美,因為最終能得到的元組中包含多少個元素,完全是取決於我們寫了幾個 infer

是否有一個辦法能 “循環” 地讀取每一種函數的重載呢?

我們來看下面這個例子:

type F1 = (arg: "a") => void
type F2 = (arg: "b") => void
type F3 = (arg: "c") => void

type F_TEST1 = (F1 & F2 & F3) extends { (a: infer R): void; } ? R : never;
// ^? "c"

欸!奇了怪了怎麼只拿到了 "c""a""b" 去哪了?哈哈不要驚慌,這裡是 TypeScript 的設計使然。現在你可以把這一點也當作是某個 TS 的 “特性”、作為體操中的工具來用就好啦!

我們得到的結論是:在 TS 中,對多種函數重載作條件判斷 infer 推算時,實際上只會以最後一種重載形式為目標。

把上面這個例子中學到的特性融合進剛才我們推算出的函數交叉類型,編寫一個幫手類型:

type IntersectionPop<U> = UnionToFnIntersections<U> extends { (a: infer A): void; } ? A : never;

現在可以取到 Union 中的每一個單項了,接下來我們需要再寫一個幫手類型幫我們把它們塞入最終的元組:

type Prepend<U, T extends any[]> =
	((a: U, ...r: T) => void) extends (...r: infer R) => void ? R : never;

很好!已經萬事俱備啦,現在我們來編寫最終的類型計算式:

type UnionToTupleRecursively<Union, Result extends any[]> = {
  1: Result;
  0: UnionToTupleRecursively<
	   Exclude<Union, IntersectionPop<Union>>, // 递归移除已经 Pop 出去的项
       Prepend<IntersectionPop<Union>, Result> // 把 Pop 出来的塞入 Result
     >
}[[Union] extends [never] ? 1 : 0];

type UnionToTuple<U> = UnionToTupleRecursively<U, []>;

其實這道題做到這裡就算結束了,但如果你和我一樣是個有點小強迫症的人,試一下這個 UnionToTuple 後你會發現一個瑕疵:

type FINAL_1 = UnionToTupleRecursively<"a" | "b", []>
//   ^? [a: "a", a: "b"]

得到的這個 FINAL_1 是一個帶 Label 的元組,雖然這並不影響其性質,它和 ["a", "b"] 使用上應該說沒有任何不同,但總歸看上去有點怪,為了移除這個 Label,可以考慮再套一層如下的轉換:

type RemoveLabels<Tuple, Result extends any[]> = Tuple extends [infer E, ...infer Rest]
  ? RemoveLabels<Rest, [...Result, E]>
  : Result

type UnionToTuple<U> = RemoveLabels<UnionToTupleRecursively<U, []>, []>;

type FINAL_2 = UnionToTupleRecursively<"a" | "b", []>
//   ^? ["a", "b"]

Union to Tuple 的題做到這裡就告一段落了,我們小結一下我們學到了的各種性質:

  1. 對裸類型參數做條件類型推算是有分配性的
  2. 一個聯合類型可以通過先把其中每一個子項都作為參數轉化為一個函數的聯合、再用參數的逆變性質轉換為一個函數的交叉
  3. 函數重載的交叉類型可以改寫為等價的接口形式
  4. 對多種函數重載作條件判斷推算時,只會取最後一種重載形式作為目標

而最近 Volar 團隊在為 Vue 的 emits 編寫 LSP 服務中的類型時遇到了個難題,我把問題的大意總結如下:

Vue 的 defineEmits 需要用戶給定一個類型參數、它會根據這個類型參數返回要給類似 (event: 'foo', arg: T1) => void & (event: 'bar', arg1: T2, arg2: T3) => void 這樣的類型。

現在我們需要從這個返回的交叉類型裡提取出所有 “第一個參數” 的類型、並進行聯合。

我們很容易推想得到,最終的類型計算式大概也會是類似上面那樣的一個遞歸,最終得到的是一個 Tuple,雖然 Union to Tuple 很難,但是 Tuple to Union 就很容易了,只需要 Tuple[number] 一下就好了:

type ExtractFirstArgRecursively<I, Result extends any[]> = {
  1: Result;
  0: ExtractFirstArgRecursively<
       NarrowIntersection<I, PopIntersectionFuncs<I>>,
       Prepend<GetIntersectionFuncsLastOneFirstArg<I>, Result>
     >
}[[I] extends [never] ? 1 : 0];

然後我們分別去實現上面這些作中間計算的幫手類型:

// 利用了我們上面總結的結論 4
type PopIntersectionFuncs<I> = I extends (...args: infer A) => infer R ? (...args: A) => R : never

type GetIntersectionFuncsLastOneFirstArg<I> = I extends (firstArg: infer F, ...rest: infer P) => void ? F : never

NarrowIntersection 這個類型有一點小坑值得一提,本來一開始我寫的是這樣的:

type NarrowIntersection<I, T> = I extends (T & infer R) ? R : never

但這樣最終得到的 Tuple 中總是有一個多餘的 never,我料想這肯定是這裡 Narrow 的時候出了問題,於是我便手寫了一下中間步驟:

type E1 = ((event: 'foo', arg: number) => void)
type E2 = ((event: 'bar', arg1: string, arg2: number) => void)
type E3 = ((event: 'fee', arg: boolean) => void)

type TT = (E1 & E2 & E3)

type Last1 = PopIntersectionFuncs<TT>
type NTT1 = NarrowIntersection<TT, Last1>
//   ^? ((event: 'foo', arg: number) => void) & ((event: 'bar', arg1: string, arg2: number) => void)
//   Result = ["fee"]

type Last2 = PopIntersectionFuncs<NTT1>
type NTT2 = NarrowIntersection<NTT1, Last2>
//   ^? (event: 'foo', arg: number) => void
//   Result = ["bar", "fee"]

type Last3 = PopIntersectionFuncs<NTT2>
type NTT3 = NarrowIntersection<NTT2, Last3>
//   ^? unknown
//   Result = ["foo", "bar", "fee"]

type Last4 = PopIntersectionFuncs<NTT3>
type NTT5 = NarrowIntersection<NTT3, Last4>
//   ^? never ---> 會終止遞歸、不會再有下一輪
//   Result = [never, "foo", "bar", "fee"]

所以歸根到底是這個 unknown 惹了禍,我們從網上援引一些好用的工具類型 IsUnknown 解決當前這個問題:

type IsAny<T> = 0 extends 1 & T ? true : false
type IsNever<T> = [T] extends [never] ? true : false
type IsUnknown<T> = IsAny<T> extends true
  ? false
  : unknown extends T
	? true
	: false

type NarrowIntersection<I, T> = I extends (T & infer R)
  ? IsUnknown<R> extends true
     ? never
     : R
  : never

很好,現在對 Volar 的那個問題來做個最終的解答:

type GetAllOverloadsFirstArg<I> = RemoveLabels<ExtractFirstArgRecursively<I, []>, []>

type FINAL = GetAllOverloadsFirstArg<TT>
// ["foo", "bar", "fee"]

這個改動的 Pull Request 詳見 這裡,代碼已合入 Volar Vue v1.8.4

參考#

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。