精讀《ObjectEntries, Shift, Reverse...》

黃子毅發表於2022-07-18

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

精讀

ObjectEntries

實現 TS 版本的 Object.entries

interface Model {
  name: string;
  age: number;
  locations: string[] | null;
}
type modelEntries = ObjectEntries<Model> // ['name', string] | ['age', number] | ['locations', string[] | null];

經過前面的鋪墊,大家應該熟悉了 TS 思維思考問題,這道題看到後第一個念頭應該是:如何先把物件轉換為聯合型別?這個問題不解決,就無從下手。

物件或陣列轉聯合型別的思路都是類似的,一個陣列轉聯合型別用 [number] 作為下標:

['1', '2', '3']['number'] // '1' | '2' | '3'

物件的方式則是 [keyof T] 作為下標:

type ObjectToUnion<T> = T[keyof T]

再觀察這道題,聯合型別每一項都是陣列,分別是 Key 與 Value,這樣就比較好寫了,我們只要構造一個 Value 是符合結構的物件即可:

type ObjectEntries<T> = {
  [K in keyof T]: [K, T[K]]
}[keyof T]

為了通過單測 ObjectEntries<{ key?: undefined }>,讓 Key 位置不出現 undefined,需要強制把物件描述為非可選 Key:

type ObjectEntries<T> = {
  [K in keyof T]-?: [K, T[K]]
}[keyof T]

為了通過單測 ObjectEntries<Partial<Model>>,得將 Value 中 undefined 移除:

// 本題答案
type RemoveUndefined<T> = [T] extends [undefined] ? T : Exclude<T, undefined>
type ObjectEntries<T> = {
  [K in keyof T]-?: [K, RemoveUndefined<T[K]>]
}[keyof T]

Shift

實現 TS 版 Array.shift

type Result = Shift<[3, 2, 1]> // [2, 1]

這道題應該是簡單難度的,只要把第一項拋棄即可,利用 infer 輕鬆實現:

// 本題答案
type Shift<T> = T extends [infer First, ...infer Rest] ? Rest : never

Tuple to Nested Object

實現 TupleToNestedObject<T, P>,其中 T 僅接收字串陣列,P 是任意型別,生成一個遞迴物件結構,滿足如下結果:

type a = TupleToNestedObject<['a'], string> // {a: string}
type b = TupleToNestedObject<['a', 'b'], number> // {a: {b: number}}
type c = TupleToNestedObject<[], boolean> // boolean. if the tuple is empty, just return the U type

這道題用到了 5 個知識點:遞迴、輔助型別、infer、如何指定物件 Key、PropertyKey,你得全部知道並組合起來才能解決該題。

首先因為返回值是個遞迴物件,遞迴過程中必定不斷修改它,因此給泛型新增第三個引數 R 儲存這個物件,並且在遞迴陣列時從最後一個開始,這樣從最內層物件開始一點點把它 “包起來”:

type TupleToNestedObject<T, U, R = U> = /** 虛擬碼
  T extends [...infer Rest, infer Last]
*/

下一步是如何描述一個物件 Key?之前 Chainable Options 例子我們學到的 K in Q,但需要注意直接這麼寫會報錯,因為必須申明 Q extends PropertyKey。最後再處理一下遞迴結束條件,即 T 變成空陣列時直接返回 R

// 本題答案
type TupleToNestedObject<T, U, R = U> = T extends [] ? R : (
  T extends [...infer Rest, infer Last extends PropertyKey] ? (
    TupleToNestedObject<Rest, U, {
      [P in Last]: R
    }>
  ) : never
)

Reverse

實現 TS 版 Array.reverse

type a = Reverse<['a', 'b']> // ['b', 'a']
type b = Reverse<['a', 'b', 'c']> // ['c', 'b', 'a']

這道題比上一題簡單,只需要用一個遞迴即可:

// 本題答案
type Reverse<T extends any[]> = T extends [...infer Rest, infer End] ? [End, ...Reverse<Rest>] : T

Flip Arguments

實現 FlipArguments<T> 將函式 T 的引數反轉:

type Flipped = FlipArguments<(arg0: string, arg1: number, arg2: boolean) => void> 
// (arg0: boolean, arg1: number, arg2: string) => void

本題與上題類似,只是反轉內容從陣列變成了函式的引數,只要用 infer 定義出函式的引數,利用 Reverse 函式反轉一下即可:

// 本題答案
type Reverse<T extends any[]> = T extends [...infer Rest, infer End] ? [End, ...Reverse<Rest>] : T

type FlipArguments<T> =
  T extends (...args: infer Args) => infer Result ? (...args: Reverse<Args>) => Result : never

FlattenDepth

實現指定深度的 Flatten:

type a = FlattenDepth<[1, 2, [3, 4], [[[5]]]], 2> // [1, 2, 3, 4, [5]]. flattern 2 times
type b = FlattenDepth<[1, 2, [3, 4], [[[5]]]]> // [1, 2, 3, 4, [[5]]]. Depth defaults to be 1

這道題比之前的 Flatten 更棘手一些,因為需要控制打平的次數。

基本想法就是,打平 Deep 次,所以需要實現打平一次的函式,再根據 Deep 值遞迴對應次:

type FlattenOnce<T extends any[], U extends any[] = []> = T extends [infer X, ...infer Y] ? (
  X extends any[] ? FlattenOnce<Y, [...U, ...X]> : FlattenOnce<Y, [...U, X]>
) : U

然後再實現主函式 FlattenDepth,因為 TS 無法實現 +、- 號運算,我們必須用陣列長度判斷與運算元組來輔助實現:

// FlattenOnce
type FlattenDepth<
  T extends any[],
  U extends number = 1,
  P extends any[] = []
> = P['length'] extends U ? T : (
  FlattenDepth<FlattenOnce<T>, U, [...P, any]>
)

當遞迴沒有達到深度 U 時,就用 [...P, any] 的方式給陣列塞一個元素,下次如果能匹配上 P['length'] extends U 說明遞迴深度已達到。

但考慮到測試用例 FlattenDepth<[1, [2, [3, [4, [5]]]]], 19260817> 會引發超長次數遞迴,需要提前終止,即如果打平後已經是平的,就不用再繼續遞迴了,此時可以用 FlattenOnce<T> extends T 判斷:

// 本題答案
// FlattenOnce
type FlattenDepth<
  T extends any[],
  U extends number = 1,
  P extends any[] = []
> = P['length'] extends U ? T : (
  FlattenOnce<T> extends T ? T : (
    FlattenDepth<FlattenOnce<T>, U, [...P, any]>
  )
)

BEM style string

實現 BEM 函式完成其規則拼接:

Expect<Equal<BEM<'btn', [], ['small', 'medium', 'large']>, 'btn--small' | 'btn--medium' | 'btn--large' >>,

之前我們瞭解了通過下標將陣列或物件轉成聯合型別,這裡還有一個特殊情況,即字串中通過這種方式申明每一項,會自動笛卡爾積為新的聯合型別:

type BEM<B extends string, E extends string[], M extends string[]> = 
  `${B}__${E[number]}--${M[number]}`

這是最簡單的寫法,但沒有考慮項不存在的情況。不如建立一個 SafeUnion 函式,當傳入值不存在時返回空字串,保證安全的跳過:

type IsNever<TValue> = TValue[] extends never[] ? true : false;
type SafeUnion<TUnion> = IsNever<TUnion> extends true ? "" : TUnion;

最終程式碼:

// 本題答案
// IsNever, SafeUnion
type BEM<B extends string, E extends string[], M extends string[]> = 
  `${B}${SafeUnion<`__${E[number]}`>}${SafeUnion<`--${M[number]}`>}`

InorderTraversal

實現 TS 版二叉樹中序遍歷:

const tree1 = {
  val: 1,
  left: null,
  right: {
    val: 2,
    left: {
      val: 3,
      left: null,
      right: null,
    },
    right: null,
  },
} as const

type A = InorderTraversal<typeof tree1> // [1, 3, 2]

首先回憶一下二叉樹中序遍歷 JS 版的實現:

function inorderTraversal(tree) {
  if (!tree) return []
  return [
    ...inorderTraversal(tree.left),
    res.push(val),
    ...inorderTraversal(tree.right)
  ]
}

對 TS 來說,實現遞迴的方式有一點點不同,即通過 extends TreeNode 來判定它不是 Null 從而遞迴:

// 本題答案
interface TreeNode {
  val: number
  left: TreeNode | null
  right: TreeNode | null
}
type InorderTraversal<T extends TreeNode | null> = [T] extends [TreeNode] ? (
  [
    ...InorderTraversal<T['left']>,
    T['val'],
    ...InorderTraversal<T['right']>
  ] 
): []

你可能會問,問什麼不能像 JS 一樣,用 null 做判斷呢?

type InorderTraversal<T extends TreeNode | null> = [T] extends [null] ? [] : (
  [ // error
    ...InorderTraversal<T['left']>,
    T['val'],
    ...InorderTraversal<T['right']>
  ] 
)

如果這麼寫會發現 TS 丟擲了異常,因為 TS 不能確定 T 此時符合 TreeNode 型別,所以要執行操作時一般採用正向判斷。

總結

這些型別挑戰題目需要靈活組合 TS 的基礎知識點才能破解,常用的包括:

  • 如何操作物件,增減 Key、只讀、合併為一個物件等。
  • 遞迴,以及輔助型別。
  • infer 知識點。
  • 聯合型別,如何從物件或陣列生成聯合型別,字串模板與聯合型別的關係。
討論地址是:精讀《ObjectEntries, Shift, Reverse...》· Issue #431 · dt-fe/weekly

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

關注 前端精讀微信公眾號

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

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

相關文章