精讀《Diff, AnyOf, IsUnion...》

黃子毅發表於2022-07-04

解決 TS 問題的最好辦法就是多練,這次解讀 type-challenges Medium 難度 25~32 題。

精讀

Diff

實現 Diff<A, B>,返回一個新物件,型別為兩個物件型別的 Diff:

type Foo = {
  name: string
  age: string
}
type Bar = {
  name: string
  age: string
  gender: number
}

Equal<Diff<Foo, Bar> // { gender: number }

首先要思考 Diff 的計算方式,A 與 B 的 Diff 是找到 A 存在 B 不存在,與 B 存在 A 不存在的值,那麼正好可以利用 Exclude<X, Y> 函式,它可以得到存在於 X 不存在於 Y 的值,我們只要用 keyof Akeyof B 代替 XY,並交替 A、B 位置就能得到 Diff:

// 本題答案
type Diff<A, B> = {
  [K in Exclude<keyof A, keyof B> | Exclude<keyof B, keyof A>]:
    K extends keyof A ? A[K] : (
      K extends keyof B ? B[K]: never
    )
}

Value 部分的小技巧我們之前也提到過,即需要用兩套三元運算子保證訪問的下標在物件中存在,即 extends keyof 的語法技巧。

AnyOf

實現 AnyOf 函式,任意項為真則返回 true,否則返回 false,空陣列返回 false

type Sample1 = AnyOf<[1, '', false, [], {}]> // expected to be true.
type Sample2 = AnyOf<[0, '', false, [], {}]> // expected to be false.

本題有幾個問題要思考:

第一是用何種判定思路?像這種判斷陣列內任意元素是否滿足某個條件的題目,都可以用遞迴的方式解決,具體是先判斷陣列第一項,如果滿足則繼續遞迴判斷剩餘項,否則終止判斷。這樣能做但比較麻煩,還有種取巧的辦法是利用 extends Array<> 的方式,讓 TS 自動幫你遍歷。

第二個是如何判斷任意項為真?為真的情況很多,我們嘗試列舉為假的 Case:0 undefined '' undefined null never []

結合上面兩個思考,本題作如下解答不難想到:

type Falsy = '' | never | undefined | null | 0 | false | []
type AnyOf<T extends readonly any[]> = T extends Falsy[] ? false : true

但會遇到這個測試用例沒通過:

AnyOf<[0, '', false, [], {}]>

如果此時把 {} 補在 Falsy 裡,會發現除了這個 case 外,其他判斷都掛了,原因是 { a: 1 } extends {} 結果為真,因為 {} 並不表示空物件,而是表示所有物件型別,所以我們要把它換成 Record<PropertyKey, never>,以鎖定空物件:

// 本題答案
type Falsy = '' | never | undefined | null | 0 | false | [] | Record<PropertyKey, never>
type AnyOf<T extends readonly any[]> = T extends Falsy[] ? false : true

IsNever

實現 IsNever 判斷值型別是否為 never

type A = IsNever<never>  // expected to be true
type B = IsNever<undefined> // expected to be false
type C = IsNever<null> // expected to be false
type D = IsNever<[]> // expected to be false
type E = IsNever<number> // expected to be false

首先我們可以毫不猶豫的寫下一個錯誤答案:

type IsNever<T> = T extends never ? true :false

這個錯誤答案離正確答案肯定是比較近的,但錯在無法判斷 never 上。在 Permutation 全排列題中我們就認識到了 never 在泛型中的特殊性,它不會觸發 extends 判斷,而是直接終結,致使判斷無效。

而解法也很簡單,只要繞過 never 這個特性即可,包一個陣列:

// 本題答案
type IsNever<T> = [T] extends [never] ? true :false

IsUnion

實現 IsUnion 判斷是否為聯合型別:

type case1 = IsUnion<string>  // false
type case2 = IsUnion<string|number>  // true
type case3 = IsUnion<[string|number]>  // false

這道題完全是腦筋急轉彎了,因為 TS 肯定知道傳入型別是否為聯合型別,並且會對聯合型別進行特殊處理,但並沒有暴露聯合型別的判斷語法,所以我們只能對傳入型別進行測試,推斷是否為聯合型別。

我們到現在能想到聯合型別的特徵只有兩個:

  1. 在 TS 處理泛型為聯合型別時進行分發處理,即將聯合型別拆解為獨立項一一進行判定,最後再用 | 連線。
  2. [] 包裹聯合型別可以規避分發的特性。

所以怎麼判定傳入泛型是聯合型別呢?如果泛型進行了分發,就可以反推出它是聯合型別。

難點就轉移到了:如何判斷泛型被分發了?首先分析一下,分發的效果是什麼樣:

A extends A
// 如果 A 是 1 | 2,分發結果是:
(1 extends 1 | 2) | (2 extends 1 | 2)

也就是這個表示式會被執行兩次,第一個 A 在兩次值分別為 12,而第二個 A 在兩次執行中每次都是 1 | 2,但這兩個表示式都是 true,無法體現分發的特殊性。

此時要利用包裹 [] 不分發的特性,即在分發後,由於在每次執行過程中,第一個 A 都是聯合型別的某一項,因此用 [] 包裹後必然與原始值不相等,所以我們在 extends 分發過程中,再用 [] 包裹 extends 一次,如果此時匹配不上,說明產生了分發:

type IsUnion<A> = A extends A ? (
  [A] extends [A] ? false : true
) : false

但這段程式碼依然不正確,因為在第一個三元表示式括號內,A 已經被分發,所以 [A] extends [A] 即便對聯合型別也是判定為真的,此時需要用原始值代替 extends 後面的 [A],騷操作出現了:

type IsUnion<A, B = A> = A extends A ? (
  [B] extends [A] ? false : true
) : false

雖然我們申明瞭 B = A,但過程中因為 A 被分發了,所以執行時 B 是不等於 A 的,才使得我們達成目的。[B]extends 前面是因為,B 是未被分發的,不可能被分發後的結果包含,所以分發時此條件必定為假。

最後因為測試用例有一個 never 情況,我們用剛才的 IsNever 函式提前判否即可:

// 本題答案
type IsUnion<A, B = A> = IsNever<A> extends true ? false : (
  A extends A ? (
    [B] extends [A] ? false : true
  ) : false
)

從該題我們可以深刻體會到 TS 的怪異之處,即 type X<T> = T extends ...extends 前面的 T 不一定是你看到傳入的 T,如果是聯合型別的話,會分發為單個型別分別處理。

ReplaceKeys

實現 ReplaceKeys<Obj, Keys, Targets>Obj 中每個物件的 Keys Key 型別轉化為符合 Targets 物件對應 Key 描述的型別,如果無法匹配到 Targets 則型別置為 never

type NodeA = {
  type: 'A'
  name: string
  flag: number
}

type NodeB = {
  type: 'B'
  id: number
  flag: number
}

type NodeC = {
  type: 'C'
  name: string
  flag: number
}


type Nodes = NodeA | NodeB | NodeC

type ReplacedNodes = ReplaceKeys<Nodes, 'name' | 'flag', {name: number, flag: string}> // {type: 'A', name: number, flag: string} | {type: 'B', id: number, flag: string} | {type: 'C', name: number, flag: string} // would replace name from string to number, replace flag from number to string.

type ReplacedNotExistKeys = ReplaceKeys<Nodes, 'name', {aa: number}> // {type: 'A', name: never, flag: number} | NodeB | {type: 'C', name: never, flag: number} // would replace name to never

本題別看描述很嚇人,其實非常簡單,思路:用 K in keyof Obj 遍歷原始物件所有 Key,如果這個 Key 在描述的 Keys 中,且又在 Targets 中存在,則返回型別 Targets[K] 否則返回 never,如果不在描述的 Keys 中則用在物件裡本來的型別:

// 本題答案
type ReplaceKeys<Obj, Keys, Targets> = {
  [K in keyof Obj] : K extends Keys ? (
    K extends keyof Targets ? Targets[K] : never
  ) : Obj[K]
}

Remove Index Signature

實現 RemoveIndexSignature<T> 把物件 <T> 中 Index 下標移除:

type Foo = {
  [key: string]: any;
  foo(): void;
}

type A = RemoveIndexSignature<Foo>  // expected { foo(): void }

該題思考的重點是如何將物件字串 Key 識別出來,可以用 \`${infer P}\` 是否能識別到 P 來判斷當前是否命中了字串 Key:

// 本題答案
type RemoveIndexSignature<T> = {
  [K in keyof T as K extends `${infer P}` ? P : never]: T[K]
}

Percentage Parser

實現 PercentageParser<T>,解析出百分比字串的符號位與數字:

type PString1 = ''
type PString2 = '+85%'
type PString3 = '-85%'
type PString4 = '85%'
type PString5 = '85'

type R1 = PercentageParser<PString1> // expected ['', '', '']
type R2 = PercentageParser<PString2> // expected ["+", "85", "%"]
type R3 = PercentageParser<PString3> // expected ["-", "85", "%"]
type R4 = PercentageParser<PString4> // expected ["", "85", "%"]
type R5 = PercentageParser<PString5> // expected ["", "85", ""]

這道題充分說明了 TS 沒有正則能力,儘量還是不要做正則的事情 ^_^。

回到正題,如果非要用 TS 實現,我們只能列舉各種場景:

// 本題答案
type PercentageParser<A extends string> = 
  // +/-xxx%
  A extends `${infer X extends '+' | '-'}${infer Y}%`? [X, Y, '%'] : (
    // +/-xxx
    A extends `${infer X extends '+' | '-'}${infer Y}` ? [X, Y, ''] : (
      // xxx%
      A extends `${infer X}%` ? ['', X, '%'] : (
        // xxx 包括 ['100', '%', ''] 這三種情況
        A extends `${infer X}` ? ['', X, '']: never
      )
    )
  )

這道題運用了 infer 可以無限進行分支判斷的知識。

Drop Char

實現 DropChar 從字串中移除指定字元:

type Butterfly = DropChar<' b u t t e r f l y ! ', ' '> // 'butterfly!'

這道題和 Replace 很像,只要用遞迴不斷把 C 排除掉即可:

// 本題答案
type DropChar<S, C extends string> = S extends `${infer A}${C}${infer B}` ? 
  `${A}${DropChar<B, C>}` : S

總結

寫到這,越發覺得 TS 雖然具備圖靈完備性,但在邏輯處理上還是不如 JS 方便,很多設計計算邏輯的題目的解法都不是很優雅。

但是解決這類題目有助於強化對 TS 基礎能力組合的理解與綜合運用,在解決實際型別問題時又是必不可少的。

討論地址是:精讀《Diff, AnyOf, IsUnion...》· Issue #429 · dt-fe/weekly

如果你想參與討論,請 點選這裡,每週都有新的主題,週末或週一釋出。前端精讀 - 幫你篩選靠譜的內容。

關注 前端精讀微信公眾號

<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">

版權宣告:自由轉載-非商用-非衍生-保持署名(創意共享 3.0 許可證

相關文章