精讀《MinusOne, PickByType, StartsWith...》

黃子毅發表於2022-07-11

解決 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

思路是:

  1. U 如果為空字串則匹配一切場景,直接返回 true;否則 U 可以拆為以 US(U Start) 開頭、UE(U End) 的字串進行後續判定。
  2. 接著上面的判定,如果 T 為空字串則不可能被 U 匹配,直接返回 false;否則 T 可以拆為以 TS(T Start) 開頭、TE(T End) 的字串進行後續判定。
  3. 接著上面的判定,如果 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 許可證

相關文章