精讀《type challenges - easy》

黃子毅發表於2022-06-06

TS 強型別非常好用,但在實際運用中,免不了遇到一些難以描述,反覆看官方文件也解決不了的問題,至今為止也沒有任何一篇文件,或者一套教材可以解決所有犄角旮旯的型別問題。為什麼會這樣呢?因為 TS 並不是簡單的註釋器,而是一門圖靈完備的語言,所以很多問題的解決方法藏在基礎能力裡,但你學會了基礎能力又不一定能想到這麼用。

解決該問題的最好辦法就是多練,通過實際案例不斷刺激你的大腦,讓你養成 TS 思維習慣。所以話不多說,我們今天從 type-challenges 的 Easy 難度題目開始吧。

精讀

Pick

手動實現內建 Pick<T, K> 函式,返回一個新的型別,從物件 T 中抽取型別 K:

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

type TodoPreview = MyPick<Todo, 'title' | 'completed'>

const todo: TodoPreview = {
    title: 'Clean room',
    completed: false,
}

結合例子更容易看明白,也就是 K 是一個字串,我們需要返回一個新型別,僅保留 K 定義的 Key。

第一個難點在如何限制 K 的取值,比如傳入 T 中不存在的值就要報錯。這個考察的是硬知識,只要你知道 A extends keyof B 這個語法就能聯想到。

第二個難點在於如何生成一個僅包含 K 定義 Key 的型別,你首先要知道有 { [A in keyof B]: B[A] } 這個硬知識,這樣可以重新組合一個物件:

// 程式碼 1
type Foo<T> = {
  [P in keyof T]: T[P]
}

只懂這個語法不一定能想出思路,原因是你要打破對 TS 的刻板理解,[K in keyof T] 不是一個固定模板,其中 keyof T 只是一個指代變數,它可以被換掉,如果你換掉成另一個範圍的變數,那麼這個物件的 Key 值範圍就變了,這正好契合本題的 K

// 程式碼 2(本題答案)
type MyPick<T, K in keyof T> = {
  [P in K]: T[P]
}

這個題目別看知道答案後簡單,回顧下還是有收穫的。對比上面兩個程式碼例子,你會發現,只不過是把程式碼 1 的 keyof T 從物件描述中提到了泛型定義裡而已,所以功能上沒有任何變化,但因為泛型可以由使用者傳入,所以程式碼 1 的 P in keyof T 因為沒有泛型支撐,這裡推匯出來的就是 T 的所有 Keys,而程式碼 2 雖然把程式碼挪到了泛型,但因為用的是 extends 描述,所以表示 P 的型別被約束到了 T 的 Keys,至於具體是什麼,得看使用者程式碼怎麼傳。

所以其實放到泛型裡的 K 是沒有預設值的,而寫到物件裡作為推導值就有了預設值。泛型裡給預設值的方式如下:

// 程式碼 3
type MyPick<T, K extends keyof T = keyof T> = {
  [P in K]: T[P]
}

也就是說,這樣 MyPick<Todo> 就也可以正確工作並原封不動返回 Todo 型別,也就是說,程式碼 3 在不傳第二個引數時,與程式碼 1 的功能完全一樣。仔細琢磨一下共同點與區別,為什麼程式碼 3 可以做到和程式碼 1 功能一樣,又有更強的擴充性,你對 TS 泛型的實戰理解就上了一個臺階。

Readonly

手動實現內建 Readonly<T> 函式,將物件所有屬性設定為只讀:

interface Todo {
  title: string
  description: string
}

const todo: MyReadonly<Todo> = {
  title: "Hey",
  description: "foobar"
}

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

這道題反而比第一題簡單,只要我們用 { [A in keyof B]: B[A] } 重新宣告物件,並在每個 Key 前面加上 readonly 修飾即可:

// 本題答案
type MyReadonly<T> = {
  readonly [K in keyof T]: T[K]
}

根據這個特性我們可以做很多延伸改造,比如將物件所有 Key 都設定為可選:

type Optional<T> = {
  [K in keyof T]?: T[K]
}

{ [A in keyof B]: B[A] } 給了我們描述每一個 Key 屬性細節的機會,限制我們發揮的只有想象力。

First Of Array

實現型別 First<T>,取到陣列第一項的型別:

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

type head1 = First<arr1> // expected to be 'a'
type head2 = First<arr2> // expected to be 3

這題比較簡單,很容易想到的答案:

// 本題答案
type First<T extends any[]> = T[0]

但在寫這個答案時,有 10% 腦細胞提醒我沒有判斷邊界情況,果然看了下答案,有空陣列的情況要考慮,空陣列時返回型別 never 而不是 undefined 會更好,下面幾種寫法都是答案:

type First<T extends any[]> = T extends [] ? never : T[0]
type First<T extends any[]> = T['length'] extends 0 ? never : T[0]
type First<T> = T extends [infer P, ...infer Rest] ? P : never

第一種寫法通過 extends [] 判斷 T 是否為空陣列,是的話返回 never

第二種寫法通過長度為 0 判斷空陣列,此時需要理解兩點:1. 可以通過 T['length'] 讓 TS 訪問到值長度(型別的),2. extends 0 表示是否匹配 0,即 extends 除了匹配型別,還能直接匹配值。

第三種寫法是最省心的,但也使用了 infer 關鍵字,即使你充分知道 infer 怎麼用(精讀《Typescript infer 關鍵字》),也很難想到它。用 infer 的理由是:該場景存在邊界情況,最便於理解的寫法是 “如果 T 形如 <P, ...>” 那我就返回型別 P,否則返回 never”,這句話用 TS 描述就是:T extends [infer P, ...infer Rest] ? P : never

Length of Tuple

實現型別 Length<T> 獲取元組長度:

type tesla = ['tesla', 'model 3', 'model X', 'model Y']
type spaceX = ['FALCON 9', 'FALCON HEAVY', 'DRAGON', 'STARSHIP', 'HUMAN SPACEFLIGHT']

type teslaLength = Length<tesla>  // expected 4
type spaceXLength = Length<spaceX> // expected 5

經過上一題的學習,很容易想到這個答案:

type Length<T extends any[]> = T['length']

對 TS 來說,元組和陣列都是陣列,但元組對 TS 來說可以觀測其長度,T['length'] 對元組來說返回的是具體值,而對陣列來說返回的是 number

Exclude

實現型別 Exclude<T, U>,返回 T 中不存在於 U 的部分。該功能主要用在聯合型別場景,所以我們直接用 extends 判斷就行了:

// 本題答案
type Exclude<T, U> = T extends U ? never : T

實際執行效果:

type C = Exclude<'a' | 'b', 'a' | 'c'> // 'b'

看上去有點不那麼好理解,這是因為 TS 對聯合型別的執行是分配率的,即:

Exclude<'a' | 'b', 'a' | 'c'>
// 等價於
Exclude<'a', 'a' | 'c'> | Exclude<'b', 'a' | 'c'>

Awaited

實現型別 Awaited,比如從 Promise<ExampleType> 拿到 ExampleType

首先 TS 永遠不會執行程式碼,所以腦子裡不要有 “await 得等一下才知道結果” 的念頭。該題關鍵就是從 Promise<T> 中抽取型別 T,很適合用 infer 做:

type MyAwaited<T> = T extends Promise<infer U> ? U : never

然而這個答案還不夠標準,標準答案考慮了巢狀 Promise 的場景:

// 該題答案
type MyAwaited<T extends Promise<unknown>> = T extends Promise<infer P>
  ? P extends Promise<unknown> ? MyAwaited<P> : P
  : never

如果 Promise<P> 取到的 P 還形如 Promise<unknown>,就遞迴呼叫自己 MyAwaited<P>。這裡提到了遞迴,也就是 TS 型別處理可以是遞迴的,所以才有了後面版本做尾遞迴優化。

If

實現型別 If<Condition, True, False>,當 Ctrue 時返回 T,否則返回 F

type A = If<true, 'a', 'b'>  // expected to be 'a'
type B = If<false, 'a', 'b'> // expected to be 'b'

之前有提過,extends 還可以用來判定值,所以果斷用 extends true 判斷是否命中了 true 即可:

// 本題答案
type If<C, T, F> = C extends true ? T : F

Concat

用型別系統實現 Concat<P, Q>,將兩個陣列型別連起來:

type Result = Concat<[1], [2]> // expected to be [1, 2]

由於 TS 支援陣列解構語法,所以可以大膽的嘗試這麼寫:

type Concat<P extends any[], Q extends any[]> = [...P, ...Q]

考慮到 Concat 函式應該也能接收非陣列型別,所以做一個判斷,為了方便書寫,把 extends 從泛型定義位置挪到 TS 型別推斷的執行時:

// 本題答案
type Concat<P, Q> = [
  ...P extends any[] ? P : [P],
  ...Q extends any[] ? Q : [Q],
]

解決這題需要信念,相信 TS 可以像 JS 一樣寫邏輯。這些能力都是版本升級時漸進式提供的,所以需要不斷閱讀最新 TS 特性,快速將其理解為固化知識,其實還是有一定難度的。

Includes

用型別系統實現 Includes<T, K> 函式:

type isPillarMen = Includes<['Kars', 'Esidisi', 'Wamuu', 'Santana'], 'Dio'> // expected to be `false`

由於之前的經驗,很容易做下面的聯想:

// 如果題目要求是這樣
type isPillarMen = Includes<'Kars' | 'Esidisi' | 'Wamuu' | 'Santana', 'Dio'>
// 那我就能用 extends 輕鬆解決了
type Includes<T, K> = K extends T ? true : false

可惜第一個輸入是陣列型別,extends 可不支援判定 “陣列包含” 邏輯,此時要了解一個新知識點,即 TS 判斷中的 [number] 下標。不僅這道題,以後很多困難題都需要它作為基礎知識。

[number] 下標表示任意一項,而 extends T[number] 就可以實現陣列包含的判定,因此下面的解法是有效的:

type Includes<T extends any[], K> = K extends T[number] ? true : false

但翻答案後發現這並不是標準答案,還真找到一個反例:

type Includes<T extends any[], K> = K extends T[number] ? true : false
type isPillarMen = Includes<[boolean], false> // true

原因很簡單,truefalse 都繼承自 boolean,所以 extends 判斷的界限太寬了,題目要求的是精確值匹配,故上面的答案理論上是錯的。

標準答案是每次判斷陣列第一項,並遞迴(講真覺得這不是 easy 題),分別有兩個難點。

第一如何寫 Equal 函式?比較流行的方案是這個:

type Equal<X, Y> =
  (<T>() => T extends X ? 1 : 2) extends
  (<T>() => T extends Y ? 1 : 2) ? true : false

關於如何寫 Equal 函式還引發了一次 小討論,上面的程式碼構造了兩個函式,這兩個函式內的 T 屬於 deferred(延遲)判斷的型別,該型別判斷依賴於內部 isTypeIdenticalTo 函式完成判斷。

有了 Equal 後就簡單了,我們用解構 + infer + 遞迴的方式做就可以了:

// 本題答案
type Includes<T extends any[], K> =
  T extends [infer F, ...infer Rest] ?
    Equal<F, K> extends true ?
      true
      : Includes<Rest, K>
    : false

每次取陣列第一個值判斷 Equal,如果不匹配則拿剩餘項遞迴判斷。這個函式組合了不少 TS 知識,比如:

  • 遞迴
  • 解構
  • infer
  • extends true

可以發現,就為了解決 true extends booleantrue 的問題,我們繞了一大圈使用了更復雜的方式來實現,這在 TS 體操中也算是常態,解決問題需要耐心。

Push

實現 Push<T, K> 函式:

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

這道題真的很簡單,用解構就行了:

// 本題答案
type Push<T extends any[], K> = [...T, K]

可見,想要輕鬆解決一個 TS 簡單問題,首先你需要能解決一些困難問題 ?。

Unshift

實現 Unshift<T, K> 函式:

type Result = Unshift<[1, 2], 0> // [0, 1, 2,]

Push 基礎上改下順序就行了:

// 本題答案
type Unshift<T extends any[], K> = [K, ...T]

Parameters

實現內建函式 Parameters

Parameters 可以拿到函式的引數型別,直接用 infer 實現即可,也比較簡單:

type Parameters<T> = T extends (...args: infer P) => any ? P : []

infer 可以很方便從任何具體的位置取值,屬於典型難懂易用的語法。

總結

學會 TS 基礎語法後,活用才是關鍵。

討論地址是:精讀《type challenges - easy》· Issue #422 · dt-fe/weekly

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

關注 前端精讀微信公眾號

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

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

相關文章