精讀《Get return type, Omit, ReadOnly...》

黃子毅發表於2022-06-13

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

精讀

Get Return Type

實現非常經典的 ReturnType<T>

const fn = (v: boolean) => {
  if (v)
    return 1
  else
    return 2
}

type a = MyReturnType<typeof fn> // should be "1 | 2"

首先不要被例子嚇到了,覺得必須執行完程式碼才知道返回型別,其實 TS 已經幫我們推導好了返回型別,所以上面的函式 fn 的型別已經是這樣了:

const fn = (v: boolean): 1 | 2 => { ... }

我們要做的就是把函式返回值從內部抽出來,這非常適合用 infer 實現:

// 本題答案
type MyReturnType<T> = T extends (...args: any[]) => infer P ? P : never

infer 配合 extends 是解構複雜型別的神器,如果對上面程式碼不能一眼理解,說明對 infer 熟悉度還是不夠,需要多看。

Omit

實現 Omit<T, K>,作用恰好與 Pick<T, K> 相反,排除物件 T 中的 K key:

interface Todo {
  title: string
  description: string
  completed: boolean
}

type TodoPreview = MyOmit<Todo, 'description' | 'title'>

const todo: TodoPreview = {
  completed: false,
}

這道題比較容易嘗試的方案是:

type MyOmit<T, K extends keyof T> = {
  [P in keyof T]: P extends K ? never : T[P]
}

其實仍然包含了 descriptiontitle 這兩個 Key,只是這兩個 Key 型別為 never,不符合要求。

所以只要 P in keyof T 寫出來了,後面怎麼寫都無法將這個 Key 抹去,我們應該從 Key 下手:

type MyOmit<T, K extends keyof T> = {
  [P in (keyof T extends K ? never : keyof T)]: T[P]
}

但這樣寫仍然不對,我們思路正確,即把 keyof T 中歸屬於 K 的排除,但因為前後 keyof T 並沒有關聯,所以需要藉助 Exclude 告訴 TS,前後 keyof T 是同一個指代(上一講實現過 Exclude):

// 本題答案
type MyOmit<T, K extends keyof T> = {
  [P in Exclude<keyof T, K>]: T[P]
}

type Exclude<T, U> = T extends U ? never : T

這樣就正確了,掌握該題的核心是:

  1. 三元判斷還可以寫在 Key 位置。
  2. JS 抽不抽函式效果都一樣,但 TS 需要推斷,很多時候抽一個函式出來就是為了告訴 TS “是同一指代”。

當然既然都用上了 Exclude,我們不如再結合 Pick,寫出更優雅的 Omit 實現:

// 本題優雅答案
type MyOmit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>

Readonly 2

實現 MyReadonly2<T, K>,讓指定的 Key K 成為 ReadOnly:

interface Todo {
  title: string
  description: string
  completed: boolean
}

const todo: MyReadonly2<Todo, 'title' | 'description'> = {
  title: "Hey",
  description: "foobar",
  completed: false,
}

todo.title = "Hello" // Error: cannot reassign a readonly property
todo.description = "barFoo" // Error: cannot reassign a readonly property
todo.completed = true // OK

該題乍一看蠻難的,因為 readonly 必須定義在 Key 位置,但我們又沒法在這個位置做三元判斷。其實利用之前我們自己做的 PickOmit 以及內建的 Readonly 組合一下就出來了:

// 本題答案
type MyReadonly2<T, K extends keyof T> = Readonly<Pick<T, K>> & Omit<T, K>

即我們可以將物件一分為二,先 PickK Key 部分設定為 Readonly,再用 & 合併上剩下的 Key,正好用到上一題的函式 Omit,完美。

Deep Readonly

實現 DeepReadonly<T> 遞迴所有子元素:

type X = { 
  x: { 
    a: 1
    b: 'hi'
  }
  y: 'hey'
}

type Expected = { 
  readonly x: { 
    readonly a: 1
    readonly b: 'hi'
  }
  readonly y: 'hey' 
}

type Todo = DeepReadonly<X> // should be same as `Expected`

這肯定需要用型別遞迴實現了,既然要遞迴,肯定不能依賴內建 Readonly 函式,我們需要將函式展開手寫:

// 本題答案
type DeepReadonly<T> = {
  readonly [K in keyof T]: T[K] extends Object> ? DeepReadonly<T[K]> : T[K]
}

這裡 Object 也可以用 Record<string, any> 代替。

Tuple to Union

實現 TupleToUnion<T> 返回元組所有值的集合:

type Arr = ['1', '2', '3']

type Test = TupleToUnion<Arr> // expected to be '1' | '2' | '3'

該題將元組型別轉換為其所有值的可能集合,也就是我們希望用所有下標訪問這個陣列,在 TS 裡用 [number] 作為下標即可:

// 本題答案
type TupleToUnion<T extends any[]> = T[number]

Chainable Options

直接看例子比較好懂:

declare const config: Chainable

const result = config
  .option('foo', 123)
  .option('name', 'type-challenges')
  .option('bar', { value: 'Hello World' })
  .get()

// expect the type of result to be:
interface Result {
  foo: number
  name: string
  bar: {
    value: string
  }
}

也就是我們實現一個相對複雜的 Chainable 型別,擁有該型別的物件可以 .option(key, value) 一直鏈式呼叫下去,直到使用 get() 後拿到聚合了所有 option 的物件。

如果我們用 JS 實現該函式,肯定需要在當前閉包儲存 Object 的值,然後提供 get 直接返回,或 option 遞迴併傳入新的值。我們不妨用 Class 來實現:

class Chain {
  constructor(previous = {}) {
    this.obj = { ...previous }
  }
  
  obj: Object
  get () {
    return this.obj
  }
  option(key: string, value: any) {
    return new Chain({
      ...this.obj,
      [key]: value
    })
  }
}

const config = new Chain()

而本地要求用 TS 實現,這就比較有趣了,正好對比一下 JS 與 TS 的思維。先打個岔,該題用上面 JS 方式寫出來後,其實型別也就出來了,但用 TS 完整實現型別也另有其用,特別在一些複雜函式場景,需要用 TS 系統描述型別,JS 真正實現時拿到 any 型別做純執行時處理,將型別與執行時分離開。

好我們回到題目,我們先把 Chainable 的框架寫出來:

type Chainable = {
  option: (key: string, value: any) => any
  get: () => any
}

問題來了,如何用型別描述 option 後還可以接 optionget 呢?還有更麻煩的,如何一步一步將型別傳導下去,讓 get 知道我此時拿的型別是什麼呢?

Chainable 必須接收一個泛型,這個泛型預設值是個空物件,所以 config.get() 返回一個空物件也是合理的:

type Chainable<Result = {}> = {
  option: (key: string, value: any) => any
  get: () => Result
}

上面的程式碼對於第一層是完全沒問題的,直接呼叫 get 返回的就是空物件。

第二步解決遞迴問題:

// 本題答案
type Chainable<Result = {}> = {
  option: <K extends string, V>(key: K, value: V) => Chainable<Result & {
    [P in K]: V
  }>
  get: () => Result
}

遞迴思維大家都懂就不贅述了。這裡有個看似不值得一提,但確實容易坑人的地方,就是如何描述一個物件僅包含一個 Key 值,這個值為泛型 K 呢?

// 這是錯的,因為描述了一大堆型別
{
  [K] : V
}

// 這也是錯的,這個 K 就是字面量 K,而非你希望的型別指代
{
  K: V
}

所以必須使用 TS “習慣法” 的 [K in keyof T] 的套路描述,即便我們知道 T 只有一個固定的型別。可見 JS 與 TS 完全是兩套思維方式,所以精通 JS 不必然精通 TS,TS 還是要大量刷題培養思維的。

Last of Array

實現 Last<T> 獲取元組最後一項的型別:

type arr1 = ['a', 'b', 'c']
type arr2 = [3, 2, 1]

type tail1 = Last<arr1> // expected to be 'c'
type tail2 = Last<arr2> // expected to be 1

我們之前實現過 First,類似的,這裡無非是解構時把最後一個描述成 infer

// 本題答案
type Last<T> = T extends [...infer Q, infer P] ? P : never

這裡要注意,infer Q 有人第一次可能會寫成:

type Last<T> = T extends [...Others, infer P] ? P : never

發現報錯,因為 TS 裡不可能隨便使用一個未定義的泛型,而如果把 Others 放在 Last<T, Others> 裡,你又會面臨一個 TS 大難題:

type Last<T, Others extends any[]> = T extends [...Others, infer P] ? P : never

// 必然報錯
Last<arr2>

因為 Last<arr2> 僅傳入了一個引數,必然報錯,但第一個引數是使用者給的,第二個引數是我們推匯出來的,這裡既不能用預設值,又不能不寫,無解了。

如果真的硬著頭皮要這麼寫,必須藉助 TS 還未通過的一項特性:部分型別引數推斷,舉個例子,很可能以後的語法是:

type Last<T, Others extends any[] = infer> = T extends [...Others, infer P] ? P : never

這樣首先傳參只需要一個了,而且還申明瞭第二個引數是一個推斷型別。不過該提案還未支援,而且本質上和把 infer 寫到表示式裡面含義和效果也都一樣,所以對這道題來說就不用折騰了。

Pop

實現 Pop<T>,返回去掉元組最後一項之後的型別:

type arr1 = ['a', 'b', 'c', 'd']
type arr2 = [3, 2, 1]

type re1 = Pop<arr1> // expected to be ['a', 'b', 'c']
type re2 = Pop<arr2> // expected to be [3, 2]

這道題和 Last 幾乎完全一樣,返回第一個解構值就行了:

// 本題答案
type Pop<T> = T extends [...infer Q, infer P] ? Q : never

總結

從題目中很明顯能看出 TS 思維與 JS 思維有很大差異,想要真正掌握 TS,大量刷題是必須的。

討論地址是:精讀《Get return type, Omit, ReadOnly...》· Issue #422 · dt-fe/weekly

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

關注 前端精讀微信公眾號

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

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

相關文章