精讀《Permutation, Flatten, Absolute...》

黃子毅發表於2022-06-27

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

精讀

Permutation

實現 Permutation 型別,將聯合型別替換為可能的全排列:

type perm = Permutation<'A' | 'B' | 'C'>; // ['A', 'B', 'C'] | ['A', 'C', 'B'] | ['B', 'A', 'C'] | ['B', 'C', 'A'] | ['C', 'A', 'B'] | ['C', 'B', 'A']

看到這題立馬聯想到 TS 對多個聯合型別泛型處理是採用分配律的,在第一次做到 Exclude 題目時遇到過:

Exclude<'a' | 'b', 'a' | 'c'>
// 等價於
Exclude<'a', 'a' | 'c'> | Exclude<'b', 'a' | 'c'>

所以這題如果能 “遞迴觸發聯合型別分配率”,就有戲解決啊。但觸發的條件必須存在兩個泛型,而題目傳入的只有一個,我們只好創造第二個泛型,使其預設值等於第一個:

type Permutation<T, U = T>

這樣對本題來說,會做如下展開:

Permutation<'A' | 'B' | 'C'>
// 等價於
Permutation<'A' | 'B' | 'C', 'A' | 'B' | 'C'>
// 等價於
Permutation<'A', 'A' | 'B' | 'C'> | Permutation<'B', 'A' | 'B' | 'C'> | Permutation<'C', 'A' | 'B' | 'C'>

對於 Permutation<'A', 'A' | 'B' | 'C'> 來說,排除掉對自身的組合,可形成 'A', 'B''A', 'C' 組合,之後只要再遞迴一次,再拼一次,把已有的排除掉,就形成了 A 的全排列,以此類推,形成所有字母的全排列。

這裡要注意兩點:

  1. 如何排除掉自身?Exclude<T, P> 正合適,該函式遇到 T 在聯合型別 P 中時,會返回 never,否則返回 T
  2. 遞迴何時結束?每次遞迴時用 Exclude<U, T> 留下沒用過的組合,最後一次組合用完一定會剩下 never,此時終止遞迴。
// 本題答案
type Permutation<T, U = T> = [T] extends [never] ? [] : T extends U ? [T, ...Permutation<Exclude<U, T>>] : []

驗證一下答案,首先展開 Permutation<'A', 'B', 'C'>

'A' extends 'A' | 'B' | 'C' ? ['A', ...Permutation<'B' | 'C'>] : []
'B' extends 'A' | 'B' | 'C' ? ['B', ...Permutation<'A' | 'C'>] : []
'C' extends 'A' | 'B' | 'C' ? ['C', ...Permutation<'A' | 'B'>] : []

我們再展開第一行 Permutation<'B' | 'C'>

'B' extends 'B' | 'C' ? ['B', ...Permutation<'C'>] : []
'C' extends 'B' | 'C' ? ['C', ...Permutation<'B'>] : []

再展開第一行的 Permutation<'C'>:

'C' extends 'C' ? ['C', ...Permutation<never>] : []

此時已經完成全排列,但我們還要處理一下 Permutation<never>,使其返回 [] 並終止遞迴。那為什麼要用 [T] extends [never] 而不是 T extends never 呢?

如果我們用 T extends never 代替本題答案,輸出結果是 never,原因如下:

type X = never extends never ? 1 : 0 // 1

type Custom<T> = T extends never ? 1 : 0
type Y = Custom<never> // never

理論上相同的程式碼,為什麼用泛型後輸出就變成 never 了呢?原因是 TS 在做 T extends never ? 時,會對聯合型別進行分配,此時有一個特例,即當 T = never 時,會跳過分配直接返回 T 本身,所以三元判斷程式碼實際上沒有執行。

[T] extends [never] 這種寫法可以避免 TS 對聯合型別進行分配,繼而繞過上面的問題。

Length of String

實現 LengthOfString<T> 返回字串 T 的長度:

LengthOfString<'abc'> // 3

破解此題你需要知道一個前提,即 TS 訪問陣列型別的 [length] 屬性可以拿到長度值:

['a','b','c']['length'] // 3

也就是說,我們需要把 'abc' 轉化為 ['a', 'b', 'c']

第二個需要了解的前置知識是,用 infer 指代字串時,第一個指代指向第一個字母,第二個指向其餘所有字母:

'abc' extends `${infer S}${infer E}` ? S : never // 'a'

那轉換後的陣列存在哪呢?類似 js,我們弄第二個預設值泛型儲存即可:

// 本題答案
type LengthOfString<S, N extends any[] = []> = S extends `${infer S}${infer E}` ? LengthOfString<E, [...N, S]> : N['length']

思路就是,每次把字串第一個字母拿出來放到陣列 N 的第一項,直到字串被取完,直接拿此時的陣列長度。

Flatten

實現型別 Flatten:

type flatten = Flatten<[1, 2, [3, 4], [[[5]]]]> // [1, 2, 3, 4, 5]

此題一看就需要遞迴:

// 本題答案
type Flatten<T extends any[], Result extends any[] = []> = T extends [infer Start, ...infer Rest] ? (
  Start extends any[] ? Flatten<Rest, [...Result, ...Flatten<Start>]> : Flatten<Rest, [...Result, Start]>
) : Result

這道題看似答案複雜,其實還是用到了上一題的套路:遞迴時如果需要儲存臨時變數,用泛型預設值來儲存

本題我們就用 Result 這個泛型儲存打平後的結果,每次拿到陣列第一個值,如果第一個值不是陣列,則直接存進去繼續遞迴,此時 T 自然是剩餘的 Rest;如果第一個值是陣列,則將其打平,此時有個精彩的地方,即 ...Start 打平後依然可能是陣列,比如 [[5]] 就套了兩層,能不能想到 ...Flatten<Start> 繼續複用遞迴是解題關鍵。

Append to object

實現 AppendToObject:

type Test = { id: '1' }
type Result = AppendToObject<Test, 'value', 4> // expected to be { id: '1', value: 4 }

結合之前刷題的經驗,該題解法很簡單,注意 K in Key 可以給物件擴充某些指定 Key:

// 本題答案
type AppendToObject<Obj, Key extends string, Value> = Obj & {
  [K in Key]: Value
}

當然也有不用 Obj & 的寫法,即把原始物件和新 Key, Value 合在一起的描述方式:

// 本題答案
type AppendToObject<T, U extends number | string | symbol, V> = {
  [key in (keyof T) | U]: key extends U ? V : T[Exclude<key, U>]
}

Absolute

實現 Absolute 將數字轉成絕對值:

type Test = -100;
type Result = Absolute<Test>; // expected to be "100"

該題重點是把數字轉成絕對值字串,所以我們可以用字串的方式進行匹配:

// 本題答案
type Absolute<T extends number> = `${T}` extends `-${infer R}` ? R : `${T}`

為什麼不用 T extends 來判斷呢?因為 T 是數字,這樣寫無法匹配符號的字串描述。

String to Union

實現 StringToUnion 將字串轉換為聯合型別:

type Test = '123';
type Result = StringToUnion<Test>; // expected to be "1" | "2" | "3"

還是老套路,用一個新的泛型儲存答案,遞迴即可:

// 本題答案
type StringToUnion<T, P = never> = T extends `${infer F}${infer R}` ? StringToUnion<R, P | F> : P

當然也可以不依託泛型儲存答案,因為該題比較特殊,可以直接用 |

// 本題答案
type StringToUnion<T> = T extends `${infer F}${infer R}` ? F | StringToUnion<R> : never

Merge

實現 Merge 合併兩個物件,衝突時後者優先:

type foo = {
  name: string;
  age: string;
}
type coo = {
  age: number;
  sex: string
}

type Result = Merge<foo,coo>; // expected to be {name: string, age: number, sex: string}

這道題答案甚至是之前題目的解題步驟,即用一個物件描述 + keyof 的思維:

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

只要知道 in keyof 支援元組,值部分用 extends 進行區分即可,很簡單。

KebabCase

實現駝峰轉橫線的函式 KebabCase:

KebabCase<'FooBarBaz'> // 'foo-bar-baz'

還是老套路,用第二個引數儲存結果,用遞迴的方式遍歷字串,遇到大寫字母就轉成小寫並新增上 -,最後把開頭的 - 幹掉就行了:

// 本題答案
type KebabCase<S, U extends string = ''> = S extends `${infer F}${infer R}` ? (
  Lowercase<F> extends F ? KebabCase<R, `${U}${F}`> : KebabCase<R, `${U}-${Lowercase<F>}`>
) : RemoveFirstHyphen<U>

type RemoveFirstHyphen<S> = S extends `-${infer Rest}` ? Rest : S

分開寫就非常容易懂了,首先 KebabCase 每次遞迴取第一個字元,如何判斷這個字元是大寫呢?只要小寫不等於原始值就是大寫,所以判斷條件就是 Lowercase<F> extends F 的 false 分支。然後再寫個函式 RemoveFirstHyphen 把字串第一個 - 幹掉即可。

總結

TS 是一門程式語言,而不是一門簡單的描述或者修飾符,很多複雜型別問題要動用邏輯思維來實現,而不是查查語法就能簡單實現。

討論地址是:精讀《Permutation, Flatten, Absolute...》· Issue #426 · dt-fe/weekly

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

關注 前端精讀微信公眾號

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

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

相關文章