解決 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
的全排列,以此類推,形成所有字母的全排列。
這裡要注意兩點:
- 如何排除掉自身?
Exclude<T, P>
正合適,該函式遇到T
在聯合型別P
中時,會返回never
,否則返回T
。 - 遞迴何時結束?每次遞迴時用
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 許可證)