TS類型體操入門 —— 實(shí)現(xiàn)DeepKeyOf
大廠技術(shù)??堅(jiān)持周更??精選好文
背景
我們知道,在ts中提供了keyof關(guān)鍵字讓我們能夠獲取一個(gè)interface的全部的key
interface?Stu?{
????name:?string;
????age:?number;
}
type?keys?=?keyof?Stu;?//?type?keys?=?'name'?|?'age'
但并沒有提供下面這樣的遞歸地獲取一個(gè)interface的key的能力
interface?Stu?{
????name:?string;
????age:?number;
????nest:?{
????????a:?{
????????????b:?number;
????????}
????}
}
//?expected
type?deepKeys?=?deepkeyof?Stu;?//?type?deepKeys?=?'name'?|?'age'?|?'nest'?|?'nest.a'?|?'nest.a.b'
可實(shí)際上這種能力非常有用,比如在類似于lodash.get的這種場景下,如果能夠有類似于deepkeyof這樣的能力,我們就可以直接將path的可能參數(shù)都提前限定下來,而不只是簡單的設(shè)置為string。
let?obj?=?{
????a:?{
????????b:?{
????????????c:?'2333'
????????}
????}
}
_.get(obj,?'a.b.c');?//?'2333'
實(shí)現(xiàn)思路
1. 什么是類型體操
類型體操(type gymnastics)一詞最早出現(xiàn)于Haskell的文檔[1]中,我認(rèn)為可以簡單理解為類型編程,和我們平時(shí)使用JavaScript、C++等語言處理各種值的編程其實(shí)沒有本質(zhì)區(qū)別,只不過在類型編程中,處理的值是類型。
在JS中,我們在處理的值就是JS這門語言為我們提供的值(包括各種基礎(chǔ)數(shù)據(jù)類型及復(fù)雜類型),JS同時(shí)給我們提供了一系列的操作符,例如下面的 + 、* ,以及像 split 這樣的內(nèi)置的API。
let?a?=?1;
let?b?=?a?+?2;
let?c?=?b?*?5;
let?s?=?'typescript';
let?t?=?s.split('s');
在TypeScript的類型編程中,我們所處理的值變成了類型這一事物,查看以下代碼
type?Temp?=?{
????name:?string;
????age:?number;
}
type?keys?=?keyof?Temp;?//?type?keys?=?'name'?|?'age'
這里出現(xiàn)的值有 Temp ,操作符 有 keyof 。這里使用了 keyof 來提取Temp中的key。
可能看到這里,你對類型編程還是沒有一個(gè)具體的概念,我們做以下對比來感受一下類型編程和我們平時(shí)通常意義認(rèn)為的編程的區(qū)別。
定義變量
在類型編程中,我們定義的“變量”是不可變的,我們只能去生成新的變量,而不能去修改已有的變量,這一點(diǎn)和函數(shù)式編程語言類似。
//?in?JavaScript
let?a?=?1;
const?b?=?10;
//?in?type?gymnastics
type?a?=?1;
a?=?2;
type?b?=?2;
條件
類型編程中只有表達(dá)式,沒有“語句”這一概念,也就沒有所謂的條件語句,不過有extends ? :表達(dá)式可以幫助我們達(dá)到類似的目的。
//?in?JavaScript
let?max;
let?a?=?10,?b?=?20;
if?(a?>?b)?{
????max?=?a;
}?else?{
????max?=?b;
}
//?in?type?gymnastics
type?temp?=?10;
type?isNumber?=?T?extends?number???true?:?false;
type?res?=?isNumber;?//?type?res?=?true;
循環(huán)
類型編程中沒有循環(huán),不過可以用遞歸來模擬。
//?in?JavaScript
let?index?=?1;
let?n?=?10;
while(n--)?{
????index?*=?10;
}
//?in?type?gymnastics
type?path?=?'a.b.c';
type?Split?=?T?extends?`${infer?A}.${infer?B}`???A?|?Split?:?T;
type?test?=?Split;?//?type?test?=??a??|??b??|??c?
函數(shù)
ts的類型編程中,泛型相當(dāng)于是函數(shù),只不過泛型本身沒法當(dāng)做參數(shù)被傳遞給泛型
function?temp(arg:?string)?{
????return?arg?+?'123';
}
type?Temp?=?`${T}123`;
2. TS提供的部分類型操作
keyof
索引類型[2] 獲取一個(gè)interface某個(gè)字段對應(yīng)的類型
interface?Stu?{
????name:?string;
????age:?number;
????nest:?{
????????a:?{
????????????b:?number;
????????}
????}
}
type?temp?=?Stu['nest'];??//?type?temp?=?{?a:?{?b:?number;?};?}
type?temp1?=?Stu['next'?|?'name']?//?type?temp1?=?string?|?{?a:?{?b:?number;?};?}
模板字符串[3] 構(gòu)造各種各樣的字符串
type?key1?=?'nest';
type?key2?=?'a';
type?key3?=?`${key1}.${key2}`;?//?type?key3?=??nest.a?
type?key4?=?'name'?|?'nest';?//?union用于模板字符串也會得到一個(gè)union
type?key5?=?`get${key4}`;?//?type?key5?=?'getname'?|?'getnest'
//?如果模板字符串的輸入中出現(xiàn)了never,會導(dǎo)致整個(gè)字符串變成never
type?key6?=?`get${never}`;?//?type?key6?=?never
extends + infer從某個(gè)type中解構(gòu)出其組成部分
type?UnpackPromisePromise>?=?T?extends?Promise???A?:?never;
type?test?=?UnpackPromise<Promise>?//?type?test?=?number
泛型 + 遞歸ts類型編程的基礎(chǔ)
type?path?=?'a.b.c';
type?Split?=?T?extends?`${infer?A}.${infer?B}`???A?|?Split?:?T;
type?test?=?Split;?//?type?test?=??a??|??b??|??c?
mapped type基于一個(gè)已有的interface構(gòu)造一個(gè)新的interface
interface?Temp?{
????name:?string;
????age:?number;
}
type?ToFunc?=?{
????[k?in?keyof?Temp]:?(arg:?Temp[k])?=>?void;
}
//?type?ToFunc?=?{
//?????name:?(arg:?string)?=>?void;
//?????age:?(arg:?number)?=>?void;
//?}
//?甚至能把原interface中的key也改了
type?ToGetFunc?=?{
????[k?in?keyof?Temp?as?`get${k}`]:?(arg:?Temp[k])?=>?void;
}
//?type?ToGetFunc?=?{
//?????getname:?(arg:?string)?=>?void;
//?????getage:?(arg:?number)?=>?void;
//?}
3. 順序執(zhí)行方案
回到我們最初的問題,實(shí)現(xiàn)DeepKeyOf,我們可以先嘗試使用順序執(zhí)行的方式實(shí)現(xiàn)
使用keyof拿到第一層的key
interface?Stu?{
????name:?string;
????age:?number;
????nest:?{
????????a:?{
????????????b:?number;
????????}
????}
}
type?keys1?=?keyof?Stu;?//?type?keys1?=?'name'?|?'age'?|?'nest';
拿到第一層的key以后,用來獲取第二層的類型
type?types2?=?Stu[keys1];?//?type?types2?=?string?|?number?|?{?a:?{?b:?number;?}?}
將第二層中仍然有多層結(jié)構(gòu)的type過濾出來
type?OnlyObject?=?T?extends?Record???T?:?never;
type?types2_needed?=?OnlyObject;?//?type?types2_needed?=?{?a:?{?b:?number;?}?}?
把第二層的key拿出來
type?keys2?=?keyof?types2_needed;?//?type?keys2?=??a?
使用步驟4拿到的key重復(fù)步驟2
最終,我們可以拿到每一層的key
type?keys1?=?'name'?|?'age'?|?'nest';
type?keys2?=?'a';
type?keys3?=?'b';
嘗試一番后發(fā)現(xiàn)使用這樣順序執(zhí)行的方式似乎是沒法做到把不同層之間的key正確連接起來的,比如keys2中的'a'并不知道自己是由keys1中哪個(gè)key對應(yīng)的type中獲取的。
4. 遞歸方案

雖然順序執(zhí)行的方式不能解決問題,但是可以為我們的遞歸的解決方案提供思路。我們的輸入是一個(gè)任意的interface,輸出是它的各級的key連接以后的一個(gè)union。
//?input
interface?Stu?{
??name:?string;
??nest:?{
????a:?{
??????b:?number;
????};
????tt:?{
??????c:?boolean;
????};
??};
??info:?{
????score:?number;
????grade:?string;
??};
}
//?output
'name'?|?'nest'?|?'nest.a'?|?'nest.a.b'?|?'nest.tt'?|?'nest.tt.c'?|?'info'?|'info.score'?|?'info.grade'
在順序執(zhí)行步驟的第五步中,我們需要重復(fù)第二步,可以猜測我們現(xiàn)在需要解決的這個(gè)問題是可以分解為同類的子問題的。
仔細(xì)觀察,可以發(fā)現(xiàn)我們的問題可以這樣來表示
假設(shè)我們有一個(gè)泛型DeepKeyOf可以獲取我們需要的key
type?DeepKeyOf?=?xxx;
type?test?=?DeepKeyOf?//?type?test?=?'name'?|?'nest'?|?'nest.a'?|?'nest.a.b'?|?'nest.tt'?|?'nest.tt.c'?|?'info'?|'info.score'?|?'info.grade'
其內(nèi)部實(shí)現(xiàn)大概會是下面這樣
type?DeepKeyOf?=?{
????[k?in?keyof?T]:?k;
}[keyof?T];
type?DeepKeyOf?=?{
????[k?in?keyof?T]:?k?|??`??${k}??.??${DeepKeyOf} ??`??;?
}[keyof?T]
這一步可能比較跳,我們簡單解釋一下。
粉色部分用到的是前面有講到過的 mapped type,它基于傳入DeepKeyOf的T來構(gòu)造一個(gè)新的interface,它的key和T一致,value變成了k和`${k}.${DeepKeyOf的union}`
黃色部分用到的是上文提到過的 index access type,它取出粉色部分對應(yīng)的interface的全部字段對應(yīng)的類型
我們再將 Stu類型傳入,手動把這個(gè)泛型展開
interface?Stu?{
??name:?string;
??nest:?{
????a:?{
??????b:?number;
????};
????tt:?{
??????c:?boolean;
????};
??};
??info:?{
????score:?number;
????grade:?string;
??};
}
type?res?=?DeepKeyOf;
type?res?=?{
????name:?'name'?|?`${'name'}.${DeepKeyOf} `;
????nest:?'nest'?|?`${'nest'}.${DeppKeyOf<{a:?{b:?number;}?tt:?{c:?boolean;}}>}`;
????info:?'info'?|?`${'info'}.${DeepKeyOf<{score:?number;?grade:?string;}>}`;
}['name'?|?'nest'?|?'info'];
type?res?=?'name'?|?`${'name'}.${DeepKeyOf} `?|?'nest'?|?`${'nest'}.${DeppKeyOf<{a:?{b:?number;}?tt:?{c:?boolean;}}>}`?|?'info'?|?`${'info'}.${DeepKeyOf<{score:?number;?grade:?string;}>}`;
如果我們的DeepKeyOf實(shí)現(xiàn)正確的話,DeepKeyOf 應(yīng)當(dāng)返回never,DeppKeyOf<{a: {b: number;} tt: {c: boolean;}}>應(yīng)當(dāng)返回 'a' | 'a.b' ,DeepKeyOf<{score: number; grade: string;}> 應(yīng)當(dāng)返回 'score' | 'grade'
never是union “|” 這一運(yùn)算中的幺元[4],任何type與never做union運(yùn)算均為其本身
如:'name' | 'nest' | never === 'name' | 'nest'
補(bǔ)充細(xì)節(jié)
對于非原始類型,我們應(yīng)該直接返回never,如 DeepKeyOf應(yīng)當(dāng)返回never,我們修改DeepKeyOf實(shí)現(xiàn)如下
type?DeepKeyOf?=?T?extends?Record???{
????[k?in?keyof?T]:?k?|?`?${k}?.?${DeepKeyOf} ?`?;
}[keyof?T]?:?never;
上面的代碼實(shí)際上放到IDE里,會提示我們這樣一個(gè)錯(cuò)誤

這是因?yàn)?code style="font-size: 14px;word-wrap: break-word;border-radius: 4px;font-family: Operator Mono, Consolas, Monaco, Menlo, monospace;word-break: break-all;color: #9b6e23;background-color: #fff5e3;padding: 3px;margin: 3px;">keyof T在沒有對T做類型限制的情況下拿到的類型會是string | number | symbol,而模板字符串可以接收的類型是 string | number | bigint | boolean | null | undefined,這兩個(gè)type之間是不兼容的,我們需要對k的類型做進(jìn)一步的限制
type?DeepKeyOf?=?T?extends?Record???{
????[k?in?keyof?T]:?k?extends?string???k?|?`?${k}?.?${DeepKeyOf} ?`??:?never?;
}[keyof?T]?:?never;
type?DeepKeyOf?=?T?extends?Record<string,?any>???{
????[k?in?keyof?T]:?k?extends?string???k?|?`${k}.${DeepKeyOf} `?:?never;
}[keyof?T]?:?never;
interface?Stu?{
??name:?string;
??nest:?{
????a:?{
??????b:?number;
????};
????tt:?{
??????c:?boolean;
????};
??};
??info:?{
????score:?number;
????grade:?string;
??};
}
type?res?=?DeepKeyOf;?//?"name"?|?"nest"?|?"info"?|?"nest.a"?|?"nest.tt"?|?"nest.a.b"?|?"nest.tt.c"?|?"info.score"?|?"info.grade"
總結(jié)
以上,我們通過實(shí)現(xiàn)一個(gè)簡單的DeepKeyOf了解了類型編程這一概念,它和我們通常意義認(rèn)為的編程其實(shí)沒有本質(zhì)區(qū)別,只是編程中操作的值不同,使用的運(yùn)算符不同。在實(shí)際業(yè)務(wù)開發(fā)過程中,利用類型編程的知識,可以把一些類型收窄到我們實(shí)際需要的范圍,比如需要一個(gè)整數(shù)形式的字符串,我們完全可以使用`${bigint}`,而不是簡單的使用一個(gè)string。收窄到實(shí)際需要的范圍不僅可以提高開發(fā)體驗(yàn)(類型收窄后,IDE可以相應(yīng)地提供智能提示,也可以避免大量類型模板代碼),也能提高代碼的可讀性(比如一個(gè)函數(shù)的輸入輸出可以更加明確)。
ps:如果你對類型編程感興趣,可以嘗試一下這個(gè)體操庫[5],這個(gè)repo里有比較多有用的體操題,并且提供了難度分級,同時(shí)可以查看其他人的solution,幫助我們快速上手類型體操。
參考資料
Haskell的文檔: https://wiki.haskell.org/index.php?title=OOP_vs_type_classes&oldid=5437#Type_classes_is_a_sort_of_templates.2C_not_classes
[2]索引類型: https://www.typescriptlang.org/docs/handbook/2/indexed-access-types.html
[3]模板字符串: https://www.typescriptlang.org/docs/handbook/2/template-literal-types.html
[4]幺元: https://zh.wikipedia.org/zh-cn/%E5%96%AE%E4%BD%8D%E5%85%83
[5]這個(gè)體操庫: https://github.com/type-challenges/type-challenges/blob/master/README.md
???謝謝支持
以上便是本次分享的全部內(nèi)容,希望對你有所幫助^_^
喜歡的話別忘了?分享、點(diǎn)贊、收藏?三連哦~。
歡迎關(guān)注公眾號?前端Sharing?收獲大廠一手好文章~
