解決 TS 問題的最好辦法就是多練,這次解讀 type-challenges Medium 難度 33~40 題。
精讀
MinusOne
用 TS 實現 MinusOne
將一個數字減一:
type Zero = MinusOne<1> // 0
type FiftyFour = MinusOne<55> // 54
TS 沒有 “普通” 的運算能力,但涉及數字卻有一條生路,即 TS 可通過 ['length']
訪問陣列長度,幾乎所有數字計算都是通過它推匯出來的。
這道題,我們只要構造一個長度為泛型長度 -1 的陣列,獲取其 ['length']
屬性即可,但該方案有一個硬傷,無法計算負值,因為陣列長度不可能小於 0:
// 本題答案
type MinusOne<T extends number, arr extends any[] = []> = [
...arr,
''
]['length'] extends T
? arr['length']
: MinusOne<T, [...arr, '']>
該方案的原理不是原數字 -1,而是從 0 開始不斷加 1,一直加到目標數字減一。但該方案沒有通過 MinusOne<1101>
測試,因為遞迴 1000 次就是上限了。
還有一種能打破遞迴的思路,即:
type Count = ['1', '1', '1'] extends [...infer T, '1'] ? T['length'] : 0 // 2
也就是把減一轉化為 extends [...infer T, '1']
,這樣陣列 T
的長度剛好等於答案。那麼難點就變成了如何根據傳入的數字構造一個等長的陣列?即問題變成了如何實現 CountTo<N>
生成一個長度為 N
,每項均為 1
的陣列,而且生成陣列的遞迴效率也要高,否則還會遇到遞迴上限的問題。
網上有一個神仙解法,筆者自己想不到,但是可以拿出來給大家分析下:
type CountTo<
T extends string,
Count extends 1[] = []
> = T extends `${infer First}${infer Rest}`
? CountTo<Rest, N<Count>[keyof N & First]>
: Count
type N<T extends 1[] = []> = {
'0': [...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T]
'1': [...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, 1]
'2': [...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, 1, 1]
'3': [...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, 1, 1, 1]
'4': [...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, 1, 1, 1, 1]
'5': [
...T,
...T,
...T,
...T,
...T,
...T,
...T,
...T,
...T,
...T,
1,
1,
1,
1,
1
]
'6': [
...T,
...T,
...T,
...T,
...T,
...T,
...T,
...T,
...T,
...T,
1,
1,
1,
1,
1,
1
]
'7': [
...T,
...T,
...T,
...T,
...T,
...T,
...T,
...T,
...T,
...T,
1,
1,
1,
1,
1,
1,
1
]
'8': [
...T,
...T,
...T,
...T,
...T,
...T,
...T,
...T,
...T,
...T,
1,
1,
1,
1,
1,
1,
1,
1
]
'9': [
...T,
...T,
...T,
...T,
...T,
...T,
...T,
...T,
...T,
...T,
1,
1,
1,
1,
1,
1,
1,
1,
1
]
}
也就是該方法可以高效的實現 CountTo<'1000'>
產生長度為 1000,每項為 1
的陣列,更具體一點,只需要遍歷 <T>
字串長度次數,比如 1000
只要遞迴 4 次,而 10000
也只需要遞迴 5 次。
CountTo
函式體的邏輯是,如果字串 T
非空,就拆為第一個字元 First
與剩餘字元 Rest
,然後拿剩餘字元遞迴,但是把 First
一次性生成到了正確的長度。最核心的邏輯就是函式 N<T>
了,它做的其實是把 T
的陣列長度放大 10 倍再追加上當前數量的 1 在陣列末尾。
而 keyof N & First
也是神來之筆,此處本意就是訪問 First
下標,但 TS 不知道它是一個安全可訪問的下標,而 keyof N & First
最終值還是 First
,也可以被 TS 安全識別為下標。
拿 CountTo<'123'>
舉例:
第一次執行 First='1'
、Rest='23'
:
CountTo<'23', N<[]>['1']>
// 展開時,...[] 還是 [],所以最終結果為 ['1']
第二次執行 First='2'
、Rest='3'
CountTo<'3', N<['1']>['2']>
// 展開時,...[] 有 10 個,所以 ['1'] 變成了 10 個 1,追加上 N 對映表裡的 2 個 1,現在一共有 12 個 1
第三次執行 First='3'
、Rest=''
CountTo<'', N<['1', ...共 12 個]>['3']>
// 展開時,...[] 有 10 個,所以 12 個 1 變成 120 個,加上對映表中 3,一共有 123 個 1
總結一下,就是將數字 T
變成字串,從最左側開始獲取,每次都把已經積累的陣列數量乘以 10 再追加上當前值數量的 1,實現遞迴次數極大降低。
PickByType
實現 PickByType<P, Q>
,將物件 P
中型別為 Q
的 key 保留:
type OnlyBoolean = PickByType<
{
name: string
count: number
isReadonly: boolean
isEnable: boolean
},
boolean
> // { isReadonly: boolean; isEnable: boolean; }
本題很簡單,因為之前碰到 Remove Index Signature 題目時,我們用了 K in keyof P as xxx
來對 Key 位置進行進一步判斷,所以只要 P[K] extends Q
就保留,否則返回 never
即可:
// 本題答案
type PickByType<P, Q> = {
[K in keyof P as P[K] extends Q ? K : never]: P[K]
}
StartsWith
實現 StartsWith<T, U>
判斷字串 T
是否以 U
開頭:
type a = StartsWith<'abc', 'ac'> // expected to be false
type b = StartsWith<'abc', 'ab'> // expected to be true
type c = StartsWith<'abc', 'abcd'> // expected to be false
本題也比較簡單,用遞迴 + 首字元判等即可破解:
// 本題答案
type StartsWith<
T extends string,
U extends string
> = U extends `${infer US}${infer UE}`
? T extends `${infer TS}${infer TE}`
? TS extends US
? StartsWith<TE, UE>
: false
: false
: true
思路是:
U
如果為空字串則匹配一切場景,直接返回true
;否則U
可以拆為以US
(U Start) 開頭、UE
(U End) 的字串進行後續判定。- 接著上面的判定,如果
T
為空字串則不可能被U
匹配,直接返回false
;否則T
可以拆為以TS
(T Start) 開頭、TE
(T End) 的字串進行後續判定。 - 接著上面的判定,如果
TS extends US
說明此次首字元匹配了,則遞迴匹配剩餘字元StartsWith<TE, UE>
,如果首字元不匹配提前返回false
。
筆者看了一些答案後發現還有一種降維打擊方案:
// 本題答案
type StartsWith<T extends string, U extends string> = T extends `${U}${string}`
? true
: false
沒想到還可以用 ${string}
匹配任意字串進行 extends
判定,有點正則的意思了。當然 ${string}
也可以被 ${infer X}
代替,只是拿到的 X
不需要再用到了:
// 本題答案
type StartsWith<T extends string, U extends string> = T extends `${U}${infer X}`
? true
: false
筆者還試了下面的答案在字尾 Diff 部分為 string like number 時也正確:
// 本題答案
type StartsWith<T extends string, U extends string> = T extends `${U}${number}`
? true
: false
說明字串模板最通用的指代是 ${infer X}
或 ${string}
,如果要匹配特定的數字類字串也可以混用 ${number}
。
EndsWith
實現 EndsWith<T, U>
判斷字串 T
是否以 U
結尾:
type a = EndsWith<'abc', 'bc'> // expected to be true
type b = EndsWith<'abc', 'abc'> // expected to be true
type c = EndsWith<'abc', 'd'> // expected to be false
有了上題的經驗,這道題不要太簡單:
// 本題答案
type EndsWith<T extends string, U extends string> = T extends `${string}${U}`
? true
: false
這可以看出 TS 的技巧掌握了就非常簡單,但不知道就幾乎無解,或者用很笨的遞迴來解決。
PartialByKeys
實現 PartialByKeys<T, K>
,使 K
匹配的 Key 變成可選的定義,如果不傳 K
效果與 Partial<T>
一樣:
interface User {
name: string
age: number
address: string
}
type UserPartialName = PartialByKeys<User, 'name'> // { name?:string; age:number; address:string }
看到題目要求是不傳引數時和 Partial<T>
行為一直,就應該能想到應該這麼起頭寫個預設值:
type PartialByKeys<T, K = keyof T> = {}
我們得用可選與不可選分別描述兩個物件拼起來,因為 TS 不支援同一個物件下用兩個 keyof
描述,所以只能寫成兩個物件:
type PartialByKeys<T, K = keyof T> = {
[Q in keyof T as Q extends K ? Q : never]?: T[Q]
} & {
[Q in keyof T as Q extends K ? never : Q]: T[Q]
}
但不匹配測試用例,原因是最終型別正確,但因為分成了兩個物件合併無法匹配成一個物件,所以需要用一點點 Magic 行為合併:
// 本題答案
type PartialByKeys<T, K = keyof T> = {
[Q in keyof T as Q extends K ? Q : never]?: T[Q]
} & {
[Q in keyof T as Q extends K ? never : Q]: T[Q]
} extends infer R
? {
[Q in keyof R]: R[Q]
}
: never
將一個物件 extends infer R
再重新展開一遍看似無意義,但確實讓型別上合併成了一個物件,很有意思。我們也可以將其抽成一個函式 Merge<T>
來使用。
本題還有一個函式組合的答案:
// 本題答案
type Merge<T> = {
[K in keyof T]: T[K]
}
type PartialByKeys<T, K extends PropertyKey = keyof T> = Merge<
Partial<T> & Omit<T, K>
>
- 利用
Partial & Omit
來合併物件。 - 因為
Omit<T, K>
中K
有來自於keyof T
的限制,而測試用例又包含unknown
這種不存在的 Key 值,此時可以用extends PropertyKey
處理此場景。
RequiredByKeys
實現 RequiredByKeys<T, K>
,使 K
匹配的 Key 變成必選的定義,如果不傳 K
效果與 Required<T>
一樣:
interface User {
name?: string
age?: number
address?: string
}
type UserRequiredName = RequiredByKeys<User, 'name'> // { name: string; age?: number; address?: string }
和上題正好相反,答案也呼之欲出了:
type Merge<T> = {
[K in keyof T]: T[K]
}
type RequiredByKeys<T, K extends PropertyKey = keyof T> = Merge<
Required<T> & Omit<T, K>
>
等等,一個測試用例都沒過,為啥呢?仔細想想發現確實暗藏玄機:
Merge<{
a: number
} & {
a?: number
}> // 結果是 { a: number }
也就是同一個 Key 可選與必選同時存在時,合併結果是必選。上一題因為將必選 Omit
掉了,所以可選不會被必選覆蓋,但本題 Merge<Required<T> & Omit<T, K>>
,前面的 Required<T>
必選優先順序最高,後面的 Omit<T, K>
雖然本身邏輯沒錯,但無法把必選覆蓋為可選,因此測試用例都掛了。
解法就是破解這一特徵,用原始物件 & 僅包含 K
的必選物件,使必選覆蓋前面的可選 Key。後者可以 Pick
出來:
type Merge<T> = {
[K in keyof T]: T[K]
}
type RequiredByKeys<T, K extends PropertyKey = keyof T> = Merge<
T & Required<Pick<T, K>>
>
這樣就剩一個單測沒通過了:
Expect<Equal<RequiredByKeys<User, 'name' | 'unknown'>, UserRequiredName>>
我們還要相容 Pick
訪問不存在的 Key,用 extends
躲避一下即可:
// 本題答案
type Merge<T> = {
[K in keyof T]: T[K]
}
type RequiredByKeys<T, K extends PropertyKey = keyof T> = Merge<
T & Required<Pick<T, K extends keyof T ? K : never>>
>
Mutable
實現 Mutable<T>
,將物件 T
的所有 Key 變得可寫:
interface Todo {
readonly title: string
readonly description: string
readonly completed: boolean
}
type MutableTodo = Mutable<Todo> // { title: string; description: string; completed: boolean; }
把物件從不可寫變成可寫:
type Readonly<T> = {
readonly [K in keyof T]: T[K]
}
從可寫改成不可寫也簡單,主要看你是否記住了這個語法:-readonly
:
// 本題答案
type Mutable<T extends object> = {
-readonly [K in keyof T]: T[K]
}
OmitByType
實現 OmitByType<T, U>
根據型別 U 排除 T 中的 Key:
type OmitBoolean = OmitByType<
{
name: string
count: number
isReadonly: boolean
isEnable: boolean
},
boolean
> // { name: string; count: number }
本題和 PickByType
正好反過來,只要把 extends
後內容對調一下即可:
// 本題答案
type OmitByType<T, U> = {
[K in keyof T as T[K] extends U ? never : K]: T[K]
}
總結
本週的題目除了 MinusOne
那道神仙解法比較難以外,其他的都比較常見,其中 Merge
函式的妙用需要領悟一下。
討論地址是:精讀《MinusOne, PickByType, StartsWith...》· Issue #430 · dt-fe/weekly
如果你想參與討論,請 點選這裡,每週都有新的主題,週末或週一釋出。前端精讀 - 幫你篩選靠譜的內容。
關注 前端精讀微信公眾號
<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">
版權宣告:自由轉載-非商用-非衍生-保持署名(創意共享 3.0 許可證)