解決 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]
}
其實仍然包含了 description
、title
這兩個 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
這樣就正確了,掌握該題的核心是:
- 三元判斷還可以寫在 Key 位置。
- 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 位置,但我們又沒法在這個位置做三元判斷。其實利用之前我們自己做的 Pick
、Omit
以及內建的 Readonly
組合一下就出來了:
// 本題答案
type MyReadonly2<T, K extends keyof T> = Readonly<Pick<T, K>> & Omit<T, K>
即我們可以將物件一分為二,先 Pick
出 K
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
後還可以接 option
或 get
呢?還有更麻煩的,如何一步一步將型別傳導下去,讓 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 許可證)