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>
,當 C
為 true
時返回 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
原因很簡單,true
、false
都繼承自 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 boolean
為 true
的問題,我們繞了一大圈使用了更復雜的方式來實現,這在 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 許可證)