真實案例說明 TypeScript 類型體操的意義
TypeScript 類型系統(tǒng)支持類型編程,也就是對類型參數(shù)做一系列運算產(chǎn)生新的類型。比如這樣:
type isTwo<T> = T extends 2 ? true: false;
這種類型編程邏輯可以寫的很復(fù)雜,所以被戲稱為“類型體操”。
它是 TS 中最強(qiáng)大也是最復(fù)雜的部分了,屬于深水區(qū)的內(nèi)容。
很多同學(xué)不知道類型編程學(xué)了有什么用,好像做業(yè)務(wù)也用不到這個。那今天我們就來看一個具體的例子,來感受下類型體操的意義。
我們想實現(xiàn)這樣一個 JS 方法:
function parseQueryString(queryStr) {
if (!queryStr || !queryStr.length) {
return {};
}
const queryObj = {};
const items = queryStr.split('&');
items.forEach(item => {
const [key, value] = item.split('=');
if (queryObj[key]) {
if(Array.isArray(queryObj[key])) {
queryObj[key].push(value);
} else {
queryObj[key] = [queryObj[key], value]
}
} else {
queryObj[key] = value;
}
});
return queryObj;
}
這段代碼很容易看出來就是做 query string 的 parse 的,會把 'a=1&b=2&c=3' 的字符串 parse 成 { a: 1, b: 2, c: 3 } 返回。如果有同名的 key 的話,就合并到一個數(shù)組里。
JS 的邏輯大家寫的比較多,這部分很容易理解:
那如果要給這個函數(shù)加上類型,大家會怎么加呢?
我猜,大部分人會這么加:
參數(shù)是 string 類型,返回值是 parse 之后的對象類型 object。
這樣是可以的,而且 object 還可以寫成 Record,因為對象是索引類型(索引類型就是聚合多個元素的類型,比如對象、class、數(shù)組都是)。
Record 是 TS 內(nèi)置的一個高級類型,是通過映射類型的語法來生成索引類型的:
type Record = { [P in K]: T;}
比如傳入 'a' | 'b' 作為 key,1 作為 value,就可以生成這樣索引類型:
所以這里的 Record 也就是 key 為 string 類型,value 為任意類型的索引類型,可以代替 object 來用,更加語義化一點:
但是不管是返回值類型為 object 還是 Record 都存在一個問題:返回的對象不能提示出有哪些屬性:
對于習(xí)慣了 ts 的提示的同學(xué)來說,沒有提示太不爽了。怎么能讓這個函數(shù)的返回的類型有提示呢?
這就要用到類型編程了。
我們把函數(shù)的類型定義改成這樣:
聲明一個類型參數(shù) Str,約束為 string 類型,函數(shù)參數(shù)的類型指定是這個 Str,返回值的類型通過對 Str 做類型運算得到,也就是 ParseQueryString。
這個 ParseQueryString 的類型做的事情就是把傳入的 Str 通過各種類型運算產(chǎn)生對應(yīng)的索引類型。
這樣返回的類型就有提示了:
是不是很神奇!這就是類型體操的魅力!能夠?qū)崿F(xiàn)更精準(zhǔn)的類型提示。
那這個 ParseQueryString 的高級類型是怎么實現(xiàn)的呢?
其實我們實現(xiàn)過:??TS 類型體操:圖解一個復(fù)雜高級類型??,這里再講一下:
首先我們要把 'a=1&b=2&c=3' 的字符串按照 & 分割開,使用模式匹配的方式。
把提取出來的每一個 a=1、b=2、c=3 這種字符串再做一次處理,把結(jié)果合并起來返回。
也就是:
type ParseQueryString<Str extends string>
= Str extends `${infer Param}&${infer Rest}`
? MergeParams<ParseParam<Param>, ParseQueryString<Rest>>
: ParseParam<Str>;
類型參數(shù) Str 為待處理的字符串類型,通過模式匹配的方式提取 & 分割的字符串到 infer 聲明的局部變量 Param 中,剩下的放到 infer 聲明的局部變量 Rest 中。
對提取出來的 Param 再做處理,也就是 ParseParam,剩下的遞歸處理,也就是 ParseQueryString,然后把結(jié)果合并。
如果模式匹配不滿足,就說明沒有 & 了,那就把剩下的也做一次處理返回。
這里的 ParseParam 就是處理 a=1、b=2、c=3 這種字符串的,也是通過模式匹配來提?。?/p>
type ParseParam<Param extends string> =
Param extends `${infer Key}=${infer Value}`
? {
[K in Key]: Value
} : Record<string, any>;
類型參數(shù) Param 是待處理的字符串,通過模式匹配提取 = 分隔的字符串到局部變量 Key 和 Value 中,構(gòu)造成索引類型返回;
如果模式匹配不滿足,說明不是 = 分隔的字符串字面量類型,就返回 Record 代表任意索引類型。
測試下:
然后對多個索引類型的合并,就是通過映射類型的語法構(gòu)造一個新的索引類型:
type MergeParams<
OneParam extends Record<string, any>,
OtherParam extends Record<string, any>
> = {
readonly [Key in keyof OneParam | keyof OtherParam]:
Key extends keyof OneParam
? Key extends keyof OtherParam
? MergeValues<OneParam[Key], OtherParam[Key]>
: OneParam[Key]
: Key extends keyof OtherParam
? OtherParam[Key]
: never
}
類型參數(shù) OneParam 和 OtherParam 是兩個索引類型,通過 Record 來約束。
通過映射類型的語法構(gòu)造一個新的索引類型返回,Key 來自兩者的合并,也就是 Key in keyof OneParam | keyof OtherParam。并且加上一個 readonly 的修飾,這樣就是讓返回的索引類型不能被修改。
值要判斷下如果是兩者都有的值,那就做合并,否則分別取對應(yīng)的值。
合并的邏輯是這樣的:
type MergeValues<One, Other> =
One extends Other
? One
: Other extends unknown[]
? [One, ...Other]
: [One, Other];
類型參數(shù) One、Other 為待合并的兩個值的類型,如果兩個一樣就返回其中一個,否則如果是數(shù)組就合并數(shù)組,也就是 [One, ...Other],否則把兩個值合并成數(shù)組 [One, Other]。
這樣就完成了兩個索引類型的合并,測試下:
整體測試下:
成功了!我們實現(xiàn)了 ParseQueryString 的高級類型!(如果對類型體操看不明白的話,可以去看下我的小冊《TypeScript 類型體操通關(guān)秘籍》補下基礎(chǔ))。
當(dāng)然,這只是純粹的類型體操,把它用到 JS 里才是最終目的,所以我們把 parseQueryString 的類型定義改成了這樣:
把函數(shù)參數(shù)的類型傳入 ParseQueryString 的高級類型做類型運算,返回的結(jié)果作為函數(shù)返回值的類型。(這里要用 as any 把返回值斷言為 any,因為默認(rèn)推導(dǎo)的類型不精確,我們用根據(jù) Str 動態(tài)算出來的類型)
這樣也就能實現(xiàn)精準(zhǔn)的類型提示:
并且因為我們 readonly 的限制,不能修改屬性的值:
對比下沒用類型體操的時候:
就可以得出結(jié)論:
類型編程可以通過類型運算產(chǎn)生更準(zhǔn)確的類型,配合編輯器可以做更精準(zhǔn)的類型提示和檢查,這就是類型體操的意義。
總結(jié)
類型編程是 TypeScript 的深水區(qū)內(nèi)容,它是對類型做一系列類型運算后產(chǎn)生新的類型,它可以實現(xiàn)更精準(zhǔn)的類型提示和檢查。
我們通過 parseQueryString 這個函數(shù)的類型定義來直觀感受了下用類型體操和不用類型體操的區(qū)別,在類型提示這方面,體驗是相差很多的。
實現(xiàn)更精準(zhǔn)的類型提示和檢查,這就是類型體操的意義!