「來源: |前端技術(shù)江湖 ID:bigerfe」
作者:林不渡https://juejin.cn/post/6885672896128090125
前言
作為前端開發(fā)的趨勢之一,TypeScript 正在越來越普及,很多人像我一樣寫了 TS 后再也回不去了,比如寫再小的demo也要用 TS(得益于ts-node[1]),JS 只有在配置文件如Webpack(實(shí)際上,接下來肯定會有用TS寫配置文件的趨勢,如Vite)、ESLint等時才會用到。但同樣,也有部分開發(fā)者對TS持有拒絕的態(tài)度,如nodemon的作者就曾表示自己從來沒有使用過TS(見 #1565[2])。但同樣還有另外一部分人認(rèn)為TS學(xué)習(xí)成本太高,所以一直沒有開始學(xué)習(xí)的決心。
然而嚴(yán)謹(jǐn)?shù)膩碚f,TS 的學(xué)習(xí)成本實(shí)際上并不高,我認(rèn)為它可以被分成兩個部分:
預(yù)實(shí)現(xiàn)的 ES 提案,如 裝飾器(我之前的一篇文章 走近 MidwayJS:初識 TS 裝飾器與 IoC 機(jī)制[3] 中講了一些關(guān)于 TS 裝飾器的歷史, 有興趣的可以看看), 可選鏈?. ,空值合并運(yùn)算符??(和可選鏈一起在TypeScript3.7[4]中引入),類的私有成員private等。除了部分極端不穩(wěn)定的語法(說的就是你,裝飾器)以外,大部分的TS實(shí)現(xiàn)實(shí)際上就是未來的 ES 語法。
對于這一部分來說,無論你先前是只學(xué)習(xí)過 JS(就像我一樣),還是有過 Java、C#的使用經(jīng)歷,都能非常快速地上手,這也是實(shí)際開發(fā)中使用最多的部分,畢竟和另一塊-類型編程比起來,還是這一部分更接地氣。
類型編程,無論是一個普通接口(interface),還是密密麻麻的T extends SomeType ,或者是各種奇奇怪怪的工具類型(Partial、Required等),其實(shí)都屬于類型編程的范疇。這一塊對代碼的功能層面沒有任何影響,即使你一行代碼十個any,遇到類型錯誤就@ts-ignore,代碼該咋樣還是咋樣。
然而這也就是類型編程一直不受到太多重視的原因:相比于語法,它會帶來代碼量大大增多(類型定義可能接近甚至超過業(yè)務(wù)代碼量),上手成本較高等問題,但好處也是顯而易見的,那就是類型安全,如果你所在的團(tuán)隊(duì)使用Sentry或是類似的監(jiān)控平臺,對于JS代碼來說最常見的錯誤就是Cannot read property 'xxx' of undefined、undefined is not a function這種(如果有興趣了解更多,可以閱讀top-10-javascript-errors[5])。雖然TS不可能把這個錯誤直接完全抹消,但也能解決十之八九了。
另外一個特點(diǎn)是,在類型編程這一方面上,假設(shè)你花費(fèi) 1 單位腦力使用基礎(chǔ)的 TS 以及簡單的類型編程(即interface、type等),你就能夠獲得 5 個單位的回饋。但接下來,有可能你花費(fèi) 10 個單位腦力,也只能再獲得 2 個單位的回饋。所以類型編程往往不會受到過多重視。另外一個類型編程不受重視的重要原因則是,實(shí)際業(yè)務(wù)中并不會需要多么苛刻的類型定義,通常只會對接口數(shù)據(jù)以及應(yīng)用狀態(tài)等進(jìn)行定義。通常是底層框架類庫才會需要大量的條件類型、泛型、重載等。
前言鋪墊完畢,接下來就進(jìn)入正文部分。這篇文章的主要面向?qū)ο笫沁€沒有走出新手村的同學(xué),可以把本文當(dāng)成你們的新手任務(wù)。
推薦在閱讀過程中跟著敲一遍文中的代碼,畢竟TS的東西我自己幾個月沒寫都能忘個干凈。
正文部分包括:
泛型基礎(chǔ)索引類型 & 映射類型條件類型 & 分布式條件類型infer 關(guān)鍵字類型守衛(wèi) 與 is、 in 關(guān)鍵字內(nèi)置工具類型原理內(nèi)置工具類型的增強(qiáng)更多通用工具類型泛型 Generic Type
假設(shè)我們有這么一個函數(shù):
functionfoo(args: unknown): unknown{ ... }如果它接收一個字符串,返回這個字符串的部分截取。如果接收一個數(shù)字,返回這個數(shù)字的 n 倍。如果接收一個對象,返回鍵值被更改過的對象(鍵名不變)。上面這些場景有一個共同點(diǎn),即函數(shù)的返回值與入?yún)⑹峭活愋?
如果這時候需要類型定義,是否要把unknown替換為string | number | object?這樣固然可以,但別忘記我們需要的是 入?yún)⑴c返回值類型相同的效果。這個時候泛型就該登場了,泛型使得代碼段的類型定義易于重用(比如后續(xù)又多了一種接收布爾值返回布爾值的函數(shù)實(shí)現(xiàn)),并提升了靈活性與嚴(yán)謹(jǐn)性:
工程層面當(dāng)然不會寫這樣的代碼了... 但就當(dāng)個例子看吧:-)
functionfoo<T>(arg: T): T{return arg;}我們使用T來表示一個未知的類型,它是入?yún)⑴c返回值的類型,在使用時我們可以顯示指定泛型:
通常泛型只會使用單個字母。如T U K V S等。我的推薦做法是在項(xiàng)目達(dá)到一定復(fù)雜度后,使用有具體含義的泛型,如BasicSchema。
foo<string>("linbudu");const [count, setCount] = useState<number>(1);當(dāng)然也可以不指定,因?yàn)?TS 會自動推導(dǎo)出泛型的實(shí)際類型。
泛型在箭頭函數(shù)下的書寫:const foo = <T>(arg: T) => arg;如果你在 TSX 文件中這么寫,<T>可能會被識別為 JSX 標(biāo)簽,因此需要顯式告知編譯器:const foo = <T extends {}>(arg: T) => arg;
除了用在函數(shù)中,泛型也可以在類中使用:
class Foo<T, U> {constructor(public arg1: T, public arg2: U) {}public method(): T {returnthis.arg1; }}泛型除了單獨(dú)使用,也經(jīng)常與其他類型編程語法結(jié)合使用,可以說泛型就是 TS 類型編程最重要的基石。單獨(dú)對于泛型的介紹就到這里(因?yàn)閱渭兊闹v泛型實(shí)在沒有什么好講的),在接下來我們會講解更多泛型的高級使用技巧。
索引類型與映射類型
在閱讀這一部分前,你需要做好思維轉(zhuǎn)變的準(zhǔn)備,需要認(rèn)識到 類型編程實(shí)際也是編程。就像你寫業(yè)務(wù)代碼的時候常常會遍歷一個對象,而在類型編程中我們也會經(jīng)常遍歷一個接口。因此,你可以將一部分編程思路復(fù)用過來。我們實(shí)現(xiàn)一個簡單的函數(shù):
// 假設(shè)key是obj鍵名functionpickSingleValue(obj, key) {return obj[key];}要為其進(jìn)行類型定義的話,有哪些需要定義的地方?
參數(shù)obj參數(shù)key返回值這三樣之間是否存在關(guān)聯(lián)?
key必然是obj中的鍵值名之一,一定為string類型返回的值一定是obj 中的鍵值因此我們初步得到這樣的結(jié)果:
functionpickSingleValue<T>(obj: T, key: keyof T) {return obj[key];}keyof 是 索引類型查詢的語法, 它會返回后面跟著的類型參數(shù)的鍵值組成的字面量類型(literal types),舉個例子:
interface foo { a: number; b: string;}type A = keyof foo; // "a" | "b"是不是就像Object.keys()?
字面量類型是對類型的進(jìn)一步限制,比如你的狀態(tài)碼只可能是 0/1/2,那么你就可以寫成status: 0 | 1 | 2的形式。字面量類型包括字符串字面量、數(shù)字字面量、布爾值字面量。這一類細(xì)碎的基礎(chǔ)知識會被穿插在文中各個部分進(jìn)行講解,以此避免單獨(dú)講解時缺少特定場景讓相關(guān)概念顯得過于單調(diào)。
還少了返回值,如果你此前沒有接觸過此類語法,應(yīng)該會卡住,我們先聯(lián)想下for...in語法,遍歷對象時我們可能會這么寫:
const fooObj = { a: 1, b: "1" };for (const key in fooObj) {console.log(key);console.log(fooObj[key]);}和上面的寫法一樣,我們拿到了 key,就能拿到對應(yīng)的 value,那么 value 的類型也就不在話下了:
functionpickSingleValue<T>(obj: T, key: keyof T): T[keyofT] {return obj[key];}這一部分可能不好一步到位理解,解釋下:interface T { a: number; b: string;}type TKeys = keyof T; // "a" | "b"type PropAType = T["a"]; // number你用鍵名可以取出對象上的鍵值,自然也就可以取出接口上的鍵值(也就是類型)啦~
但這種寫法很明顯有可以改進(jìn)的地方:keyof出現(xiàn)了兩次,以及泛型 T 應(yīng)該被限制為對象類型,就像我們平時會做的那樣:用一個變量把多處出現(xiàn)的存起來,在類型編程里,泛型就是變量。
functionpickSingleValue<Textendsobject, UextendskeyofT>( obj: T, key: U): T[U] {return obj[key];}這里又出現(xiàn)了新東西extends... 它是啥?你可以暫時把T extends object理解為T 被限制為對象類型,U extends keyof T理解為泛型 U 必然是泛型 T 的鍵名組成的聯(lián)合類型(以字面量類型的形式,比如T的鍵包括a b c,那么U的取值只能是"a" "b" "c"之一)。具體的知識我們會在下一節(jié)條件類型講到。
假設(shè)現(xiàn)在不只要取出一個值了,我們要取出一系列值,即參數(shù)2將是一個數(shù)組,成員均為參數(shù)1的鍵名組成:
functionpick<Textendsobject, UextendskeyofT>(obj: T, keys: U[]): T[U][] {return keys.map((key) => obj[key]);}// pick(obj, ['a', 'b'])有兩個重要變化:
keys: U[] 我們知道 U 是 T 的鍵名組成的聯(lián)合類型,那么要表示一個內(nèi)部元素均是 T 鍵名的數(shù)組,就可以使用這種方式,具體的原理請參見下文的 分布式條件類型章節(jié)。T[U][] 它的原理實(shí)際上和上面一條相同,首先是T[U],代表參數(shù)1的鍵值(就像Object[Key]),之所以單獨(dú)拿出來是因?yàn)槲艺J(rèn)為它是一個很好地例子,表現(xiàn)了 TS 類型編程的組合性,你不感覺這種寫法就像搭積木一樣嗎?索引簽名 Index Signature
索引簽名用于快速建立一個內(nèi)部字段類型相同的接口,如
interface Foo { [keys: string]: string;}那么接口 Foo 就被認(rèn)定為字段全部為 string 類型。
等同于Record<string, string>
值得注意的是,由于 JS 可以同時通過數(shù)字與字符串訪問對象屬性,因此keyof Foo的結(jié)果會是string | number。
const o: Foo = {1: "蕪湖!",};o[1] === o["1"]; // true
但是一旦某個接口的索引簽名類型為number,那么使用它的對象就不能再通過字符串索引訪問,如o['1'],將會拋出Element implicitly has an 'any' type because index expression is not of type 'number'錯誤。
映射類型 Mapped Types
映射類型同樣是類型編程的重要底層組成,通常用于在舊有類型的基礎(chǔ)上進(jìn)行改造,包括接口包含字段、字段的類型、修飾符(只讀readonly 與 可選?)等等。
從一個簡單場景入手:
interface A { a: boolean; b: string; c: number; d: () =>void;}現(xiàn)在我們有個需求,實(shí)現(xiàn)一個接口,它的字段與接口 A 完全相同,但是其中的類型全部為 string,你會怎么做?直接重新聲明一個然后手寫嗎?這樣就很離譜了,我們可是機(jī)智的程序員。
如果把接口換成對象再想想,假設(shè)要拷貝一個對象(假設(shè)沒有嵌套),new 一個新的空對象,然后遍歷原先對象的鍵值對來填充新對象。再回到接口,其實(shí)也一樣:
type StringifyA<T> = { [K in keyof T]: string;};是不是很熟悉?重要的就是這個in操作符,你完全可以把它理解為for...in/for...of這種遍歷的思路,獲取到鍵名之后,鍵值就簡單了!
type Clone<T> = { [K in keyof T]: T[K];};掌握這種思路,其實(shí)你已經(jīng)接觸到一些工具類型的底層實(shí)現(xiàn)了:
你可以把工具類型理解為你平時放在 utils 文件夾下的公共函數(shù),提供了對公用邏輯(在這里則是類型編程邏輯)的封裝,比如上面的兩個類型接口就是~
先寫個最常用的Partial嘗嘗鮮,工具類型的詳細(xì)介紹我們會在專門的章節(jié)展開:
// 將接口下的字段全部變?yōu)榭蛇x的type Partial<T> = { [K in keyof T]?: T[k];};是不是特別簡單,讓你已經(jīng)脫口而出“就這!”,類似的,還可以實(shí)現(xiàn)個Readonly,把接口下的字段全部變?yōu)橹蛔x的。
條件類型 Conditional Types
條件類型的語法實(shí)際上就是三元表達(dá)式,看一個最簡單的例子:
T extends U ? X : Y如果你覺得這里的 extends 不太好理解,可以暫時簡單理解為 U 中的屬性在 T 中都有。
為什么會有條件類型?可以看到通常條件類型通常是和泛型一同使用的,聯(lián)想到泛型的使用場景,我想你應(yīng)該明白了些什么。對于類型無法即時確定的場景,使用條件類型來在運(yùn)行時動態(tài)的確定最終的類型(運(yùn)行時可能不太準(zhǔn)確,或者可以理解為,你提供的函數(shù)被他人使用時,根據(jù)他人使用時傳入的參數(shù)來動態(tài)確定需要被滿足的類型約束)。
條件類型理解起來更直觀,唯一需要有一定理解成本的就是 何時條件類型系統(tǒng)會收集到足夠的信息來確定類型,也就是說,條件類型有時不會立刻完成判斷。
在了解這一點(diǎn)前,我們先來看看條件類型常用的一個場景:泛型約束,實(shí)際上就是我們上面的例子:
functionpickSingleValue<Textendsobject, UextendskeyofT>( obj: T, key: U): T[U] {return obj[key];}這里的T extends object與U extends keyof T都是泛型約束,分別將 T 約束為對象類型 和 將 U 約束為 T 鍵名的字面量聯(lián)合類型。我們通常使用泛型約束來 收窄類型約束。
以一個使用條件類型作為函數(shù)返回值類型的例子:
declarefunctionstrOrNum<Textendsboolean>( x: T): Textendstrue ? string : number;在這種情況下,條件類型的推導(dǎo)就會被延遲,因?yàn)榇藭r類型系統(tǒng)沒有足夠的信息來完成判斷。
只有給出了所需信息(在這里是入?yún)的類型),才可以完成推導(dǎo)。
const strReturnType = strOrNum(true);const numReturnType = strOrNum(false);同樣的,就像三元表達(dá)式可以嵌套,條件類型也可以嵌套,如果你看過一些框架源碼,也會發(fā)現(xiàn)其中存在著許多嵌套的條件類型,無他,條件類型可以將類型約束收攏到非常精確的范圍內(nèi)。
type TypeName<T> = T extendsstring ? "string" : T extendsnumber ? "number" : T extendsboolean ? "boolean" : T extendsundefined ? "undefined" : T extendsFunction ? "function" : "object";分布式條件類型 Distributive Conditional Types
官方文檔對分布式條件類型的講解內(nèi)容甚至要多于條件類型,因此你也知道這玩意沒那么簡單了吧~
分布式條件類型實(shí)際上不是一種特殊的條件類型,而是其特性之一。先上概念:對于屬于裸類型參數(shù)的檢查類型,條件類型會在實(shí)例化時期自動分發(fā)到聯(lián)合類型上
原文: Conditional types in which the checked type is a naked type parameterare called distributive conditional types. Distributive conditional types are automatically distributed over union types during instantiation
先提取幾個關(guān)鍵詞,然后我們再通過例子理清這個概念:
裸類型參數(shù)實(shí)例化分發(fā)到聯(lián)合類型// 使用上面的TypeName類型別名// "string" | "function"type T1 = TypeName<string | (() => void)>;// "string" | "object"typeT2 = TypeName<string | string[]>;// "object"typeT3 = TypeName<string[] | number[]>;我們發(fā)現(xiàn)在上面的例子里,條件類型的推導(dǎo)結(jié)果都是聯(lián)合類型(T3 實(shí)際上也是,只不過相同所以被合并了),并且其實(shí)就是類型參數(shù)被依次進(jìn)行條件判斷后,再使用|組合得來的結(jié)果。
是不是 get 到了一點(diǎn)什么?我們再看另一個例子:
type Naked<T> = T extendsboolean ? "Y" : "N";type Wrapped<T> = [T] extends [boolean] ? "Y" : "N";/* * 先分發(fā)到 Naked<number> | Naked<boolean> * 所以結(jié)果是"N" | "Y" /type Distributed = Naked<number | boolean>;/ * 不會分發(fā) 直接是 [number | boolean] extends [boolean] * 這樣當(dāng)然就是"N"啦~ */type NotDistributed = Wrapped<number | boolean>;現(xiàn)在我們可以來講講這幾個概念了:
裸類型參數(shù),沒有額外被接口/類型別名/奇怪的東西包裹過的,就像被Wrapped包裹后就不能再被稱為裸類型參數(shù)。實(shí)例化,其實(shí)就是條件類型的判斷過程,就像我們前面說的,條件類型需要在收集到足夠的推斷信息之后才能進(jìn)行這個過程。在這里兩個例子的實(shí)例化過程實(shí)際上是不同的,具體會在下一點(diǎn)中介紹。分發(fā)至聯(lián)合類型的過程:對于 TypeName,它內(nèi)部的類型參數(shù) T 是沒有被包裹過的,所以TypeName<string | (() => void)>會被分發(fā)為TypeName<string> | TypeName<(() => void)>,然后再次進(jìn)行判斷,最后分發(fā)為"string" | "function"。抽象下具體過程:( A | B | C ) extends T ? X : Y// 相當(dāng)于(A extends T ? X : Y) | (B extends T ? X : Y) | (B extends T ? X : Y)一句話概括:沒有被額外包裝的聯(lián)合類型參數(shù),在條件類型進(jìn)行判定時會將聯(lián)合類型分發(fā),分別進(jìn)行判斷。
infer 關(guān)鍵字
infer是inference的縮寫,通常的使用方式是infer R,R表示 待推斷的類型。如果說,通常infer不會被直接使用,而是與條件類型一起,被放置在底層工具類型中,用于
看一個簡單的例子,用于獲取函數(shù)返回值類型的工具類型ReturnType:
const foo = (): string => {return"linbudu";};// stringtype FooReturnType = ReturnType<typeof foo>;infer的使用思路可能不是那么好習(xí)慣,我們可以用前端開發(fā)中常見的一個例子類比,頁面初始化時先顯示占位交互,像 Loading/骨架屏,在請求返回后再去渲染真實(shí)數(shù)據(jù)。infer也是這個思路,類型系統(tǒng)在獲得足夠的信息后,就能將 infer 后跟隨的類型參數(shù)推導(dǎo)出來,最后返回這個推導(dǎo)結(jié)果。
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : any;(...args: any[]) => infer R 是一個整體,這里函數(shù)的返回值類型的位置被infer R占據(jù)了。當(dāng)ReturnType被調(diào)用,泛型T被實(shí)際類型填充,如果T滿足條件類型的約束,就返回R的值,在這里R即為函數(shù)的返回值實(shí)際類型。實(shí)際上為了嚴(yán)謹(jǐn),應(yīng)當(dāng)約束泛型T為函數(shù)類型,即:type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;類似的,借著這個思路我們還可以獲得函數(shù)入?yún)㈩愋汀㈩惖臉?gòu)造函數(shù)入?yún)㈩愋?、甚至Promise 內(nèi)部的類型等,這些工具類型我們會在后面講到。
infer 其實(shí)沒有特別難消化的知識點(diǎn),它需要的只是思路的轉(zhuǎn)變,你要理解 延遲推斷的概念。
類型守衛(wèi) 與 is in 關(guān)鍵字 Type Guards
前面的內(nèi)容可能不是那么符合人類直覺,需要一點(diǎn)時間消化,這一節(jié)我們來看點(diǎn)簡單(相對)且直觀的知識點(diǎn):類型守衛(wèi)。
假設(shè)有這么一個字段,它可能字符串也可能是數(shù)字:
numOrStrProp: number | string;現(xiàn)在在使用時,你想將這個字段的聯(lián)合類型縮小范圍,比如精確到string,你可能會這么寫:
exportconst isString = (arg: unknown): boolean =>typeof arg === "string";看看這么寫的效果:
functionuseIt(numOrStr: number | string) {if (isString(numOrStr)) {console.log(numOrStr.length); }}
[圖片上傳失敗...(image-1c4856-1655631635280)]
image啊哦,看起來isString函數(shù)并沒有起到縮小類型范圍的作用,參數(shù)依然是聯(lián)合類型。這個時候就該使用is關(guān)鍵字了:
exportconst isString = (arg: unknown): arg is string =>typeof arg === "string";這個時候再去使用,就會發(fā)現(xiàn)在isString(numOrStr)為 true 后,numOrStr的類型就被縮小到了string。這只是以原始類型為成員的聯(lián)合類型,我們完全可以擴(kuò)展到各種場景上,先看一個簡單的假值判斷:
exporttype Falsy = false | "" | 0 | null | undefined;exportconst isFalsy = (val: unknown): val is Falsy => !val;是不是還挺有用?這應(yīng)該是我日常用的最多的類型別名之一了。
也可以在 in 關(guān)鍵字的加持下,進(jìn)行更強(qiáng)力的類型判斷,思考下面這個例子,要如何將 " A | B " 的聯(lián)合類型縮小到"A"?
class A {public a() {}public useA() {return"A"; }}class B {public b() {}public useB() {return"B"; }}再聯(lián)想下for...in循環(huán),它遍歷對象的屬性名,而in關(guān)鍵字也是一樣:
functionuseIt(arg: A | B): void{'a'in arg ? arg.useA() : arg.useB();}如果參數(shù)中存在a屬性,由于A、B兩個類型的交集并不包含a,所以這樣能立刻縮小范圍到A。
再看一個使用字面量類型作為類型守衛(wèi)的例子:
interface IBoy { name: "mike"; gf: string;}interface IGirl { name: "sofia"; bf: string;}functiongetLover(child: IBoy | IGirl): string{if (child.name === "mike") {return child.gf; } else {return child.bf; }}之前有個小哥問過一個問題,我想很多用 TS 寫接口的小伙伴可能都遇到過,即登錄與未登錄下的用戶信息是完全不同的接口,其實(shí)也可以使用in關(guān)鍵字解決。
interface ILogInUserProps { isLogin: boolean; name: string;}interface IUnLoginUserProps { isLogin: boolean;from: string;}type UserProps = ILogInUserProps | IUnLoginUserProps;functiongetUserInfo(user: ILogInUserProps | IUnLoginUserProps): string{return'name'in user ? user.name : user.from;}同樣的思路,還可以使用instanceof來進(jìn)行實(shí)例的類型守衛(wèi),建議聰明的你動手嘗試下~
工具類型 Tool Type
這一章是本文的最后一部分,應(yīng)該也是本文“性價(jià)比”最高的一部分了,因?yàn)榧词鼓氵€是不太懂這些工具類型的底層實(shí)現(xiàn),也不影響你把它用好。就像 Lodash 不會要求你每用一個函數(shù)都熟知原理一樣。這一部分包括TS 內(nèi)置工具類型與社區(qū)的擴(kuò)展工具類型,我個人推薦在完成學(xué)習(xí)后記錄你覺得比較有價(jià)值的工具類型,并在自己的項(xiàng)目里新建一個.d.ts文件(或是/utils/tool-types.ts)存儲它。
在繼續(xù)閱讀前,請確保你掌握了上面的知識,它們是類型編程的基礎(chǔ)。
內(nèi)置工具類型
在上面我們已經(jīng)實(shí)現(xiàn)了內(nèi)置工具類型中被使用最多的一個:
type Partial<T> = { [K in keyof T]?: T[k];};它用于將一個接口中的字段變?yōu)槿靠蛇x,除了映射類型以外,它只使用了?可選修飾符,那么我現(xiàn)在直接掏出小抄(好家伙):
去除可選修飾符:-?只讀修飾符:readonly去除只讀修飾符:-readonly恭喜,你得到了Required和Readonly(去除 readonly 修飾符的工具類型不屬于內(nèi)置的,我們會在后面看到):
type Required<T> = { [K in keyof T]-?: T[K];};type Readonly<T> = { readonly [K in keyof T]: T[K];};在上面我們實(shí)現(xiàn)了一個 pick 函數(shù):
functionpick<Textendsobject, UextendskeyofT>(obj: T, keys: U[]): T[U][] {return keys.map((key) => obj[key]);}照著這種思路,假設(shè)我們現(xiàn)在需要從一個接口中挑選一些字段:
type Pick<T, K extends keyof T> = { [P in K]: T[P];};// 期望用法type Part = Pick<A, "a" | "b">;還是映射類型,只不過現(xiàn)在映射類型的映射源是類型參數(shù)K。
既然有了Pick,那么自然要有Omit(一個是從對象中挑選部分,一個是排除部分),它和Pick的寫法非常像,但有一個問題要解決:我們要怎么表示T中剔除了K后的剩余字段?
Pick 選取傳入的鍵值,Omit 移除傳入的鍵值
這里我們又要引入一個知識點(diǎn):never類型,它表示永遠(yuǎn)不會出現(xiàn)的類型,通常被用來將收窄聯(lián)合類型或是接口,詳細(xì)可以看 尤大的知乎回答[6], 在這里 我們不做展開介紹。
在類型守衛(wèi)一節(jié),我們提到了一個用戶登錄狀態(tài)決定類型接口的例子,實(shí)際上也可以用never實(shí)現(xiàn)。
上面的場景其實(shí)可以簡化為:
// "3" | "4" | "5"type LeftFields = Exclude<"1" | "2" | "3" | "4" | "5", "1" | "2">;Exclude,字面意思看起來是排除,那么第一個參數(shù)應(yīng)該是要進(jìn)行篩選的,第二個應(yīng)該是篩選條件!先按著這個思路試試:
用排列組合的思路考慮:"1"在"1" | "2"里面嗎("1" extends "1"|"2" -> true)?在啊, 那讓它爬,"3"在嗎?不在那就讓它留下來。
這里實(shí)際上使用到了分布式條件類型的特性,假設(shè) Exclude 接收 T U 兩個類型參數(shù),T 聯(lián)合類型中的類型會依次與 U 類型進(jìn)行判斷,如果這個類型參數(shù)在 U 中,就剔除掉它(賦值為 never)
type Exclude<T, U> = T extends U ? never : T;那么 Omit:
type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;劇透下,幾乎所有使用條件類型的場景,把判斷后的賦值語句反一下,就會有新的場景,比如Exclude移除掉鍵名,那反一下就是保留鍵名:
type Extract<T, U> = T extends U ? T : never;再來看個常用的工具類型Record<Keys, Type>,通常用于生成以聯(lián)合類型為鍵名(Keys),鍵值類型為Type的新接口,比如:
type MyNav = "a" | "b" | "b";interface INavWidgets { widgets: string[]; title?: string; keepAlive?: boolean;}const router: Record<MyNav, INavWidgets> = { a: { widget: [""] }, b: { widget: [""] }, c: { widget: [""] },};其實(shí)很簡單,把Keys的每個鍵值拿出來,類型規(guī)定為Type即可
// K extends keyof any 約束K必須為聯(lián)合類型type Record<K extends keyof any, T> = { [P in K]: T;};在前面的 infer 一節(jié)中我們實(shí)現(xiàn)了用于獲取函數(shù)返回值的ReturnType:
type ReturnType<T extends (...args: any) => any> = T extends ( ...args: any) => infer R ? R : any;其實(shí)把 infer 換個位置,比如放到返回值處,它就變成了獲取參數(shù)類型的Parameters:
type Parameters<T extends (...args: any) => any> = T extends ( ...args: infer P) => any ? P : never;如果再大膽一點(diǎn),把普通函數(shù)換成類的構(gòu)造函數(shù),那么就得到了獲取構(gòu)造函數(shù)入?yún)㈩愋偷腃onstructorParameters:
type ConstructorParameters< T extendsnew (...args: any) => any> = T extendsnew (...args: infer P) => any ? P : never;加上new關(guān)鍵字來使其成為可實(shí)例化類型聲明,也就是此處的泛型約束需要一個類。
這個是獲得類的構(gòu)造函數(shù)入?yún)㈩愋?,如果把?infer 的類型放到其返回處,想想 new 一個類的返回值是什么?實(shí)例!所以我們得到了實(shí)例類型InstanceType:
type InstanceType<T extendsnew (...args: any) => any> = T extendsnew ( ...args: any) => infer R ? R : any;這幾個例子看下來,你應(yīng)該已經(jīng) get 到了那么一絲天機(jī),類型編程的確沒有特別高深晦澀的語法,它考驗(yàn)的是你對其中基礎(chǔ)部分如索引、映射、條件類型的掌握程度,以及舉一反三的能力。下面我們要學(xué)習(xí)的社區(qū)工具類型,本質(zhì)上還是各種基礎(chǔ)類型的組合,只是從常見場景下出發(fā),補(bǔ)充了官方?jīng)]有覆蓋到的部分。
模板類型相關(guān)
TypeScript 4.1[7] 中引入了模板字面量類型,使得可以使用${} 這一語法來構(gòu)造字面量類型,如:
type World = 'world';// "hello world"type Greeting = hello ${World};隨之而來的還有四個新的工具類型:
type Uppercase<S extendsstring> = intrinsic;type Lowercase<S extendsstring> = intrinsic;type Capitalize<S extendsstring> = intrinsic;type Uncapitalize<S extendsstring> = intrinsic;它們的作用就是字面意思,不做解釋了。相關(guān)的PR見 40336[8],作者Anders Hejlsberg是C#與Delphi的首席架構(gòu)師,同時也是TS的作者之一。
intrinsic代表了這些工具類型是由TS編譯器內(nèi)部實(shí)現(xiàn)的,其實(shí)也很好理解,我們無法通過類型編程來改變字面量的值,但我想按照這個趨勢,TS類型編程以后會支持調(diào)用Lodash方法也說不定。
社區(qū)工具類型
這一部分的工具類型大多來自于utility-types[9],其作者同時還有react-redux-typescript-guide[10] 和 typesafe-actions[11]這兩個優(yōu)秀作品。同時,也推薦type-fest[12]這個庫,和上面相比更加接地氣一些。其作者的作品...,我保證你直接或間接的使用過(如果不信,一定要去看看...我剛看到的時候是真的震驚的不行)。
我們由淺入深,先封裝基礎(chǔ)的類型別名和對應(yīng)的類型守衛(wèi):
exporttype Primitive = | string | number | bigint | boolean | symbol | null | undefined;exportconst isPrimitive = (val: unknown): val is Primitive => {if (val === null || val === undefined) {returntrue; }const typeDef = typeof val;const primitiveNonNullishTypes = ["string","number","bigint","boolean","symbol", ];return primitiveNonNullishTypes.indexOf(typeDef) !== -1;};exporttype Nullish = null | undefined;exporttype NonUndefined<A> = A extendsundefined ? never : A;// 實(shí)際上TS也內(nèi)置了type NonNullable<T> = T extendsnull | undefined ? never : T;Falsy和isFalsy我們已經(jīng)在上面體現(xiàn)了~
趁著對 infer 的記憶來熱乎,我們再來看一個常用的場景,提取 Promise 的實(shí)際類型:
const foo = (): Promise<string> => {returnnewPromise((resolve, reject) => { resolve("linbudu"); });};// Promise<string>type FooReturnType = ReturnType<typeof foo>;// stringtype NakedFooReturnType = PromiseType<FooReturnType>;如果你已經(jīng)熟練掌握了infer的使用,那么實(shí)際上是很好寫的,只需要用一個infer參數(shù)作為 Promise 的泛型即可:
exporttype PromiseType<T extendsPromise<any>> = T extendsPromise<infer U> ? U : never;使用infer R來等待類型系統(tǒng)推導(dǎo)出R的具體類型。
遞歸的工具類型
前面我們寫了個PartialReadonlyRequired等幾個對接口字段進(jìn)行修飾的工具類型,但實(shí)際上都有局限性,如果接口中存在著嵌套呢?
type Partial<T> = { [P in keyof T]?: T[P];};理一下邏輯:
如果不是對象類型,就只是加上?修飾符如果是對象類型,那就遍歷這個對象內(nèi)部重復(fù)上述流程。是否是對象類型的判斷我們見過很多次了, T extends object即可,那么如何遍歷對象內(nèi)部?實(shí)際上就是遞歸。
exporttype DeepPartial<T> = { [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];};utility-types內(nèi)部的實(shí)現(xiàn)實(shí)際比這個復(fù)雜,還考慮了數(shù)組的情況,這里為了便于理解做了簡化,后面的工具類型也同樣存在此類簡化。
那么DeepReadobly、 DeepRequired也就很簡單了:
exporttype DeepMutable<T> = { -readonly [P in keyof T]: T[P] extends object ? DeepMutable<T[P]> : T[P];};// 即DeepReadonlyexporttype DeepImmutable<T> = { +readonly [P in keyof T]: T[P] extends object ? DeepImmutable<T[P]> : T[P];};exporttype DeepRequired<T> = { [P in keyof T]-?: T[P] extends object | undefined ? DeepRequired<T[P]> : T[P];};尤其注意下DeepRequired,它的條件類型判斷的是 T[P] extends object | undefined,因?yàn)榍短椎膶ο箢愋涂赡苁强蛇x的(undefined),如果僅使用object,可能會導(dǎo)致錯誤的結(jié)果。
另外一種省心的方式是不進(jìn)行條件類型的判斷,直接全量遞歸所有屬性~
返回鍵名的工具類型
在有些場景下我們需要一個工具類型,它返回接口字段鍵名組成的聯(lián)合類型,然后用這個聯(lián)合類型進(jìn)行進(jìn)一步操作(比如給 Pick 或者 Omit 這種使用),一般鍵名會符合特定條件,比如:
可選/必選/只讀/非只讀的字段(非)對象/(非)函數(shù)/類型的字段來看個最簡單的函數(shù)類型字段FunctionTypeKeys:
exporttype FunctTypeKeys<T extends object> = { [K in keyof T]-?: T[K] extendsFunction ? K : never;}[keyof T];{ [K in keyof T]: ... }[keyof T]這個寫法可能有點(diǎn)詭異,拆開來看:
interface IWithFuncKeys { a: string; b: number; c: boolean; d: () =>void;}type WTFIsThis<T extends object> = { [K in keyof T]-?: T[K] extendsFunction ? K : never;};type UseIt1 = WTFIsThis<IWithFuncKeys>;很容易推導(dǎo)出UseIt1實(shí)際上就是:
type UseIt1 = { a: never; b: never; c: never; d: "d";};UseIt會保留所有字段,滿足條件的字段其鍵值為字面量類型(值為鍵名)
加上后面一部分:
// "d"type UseIt2 = UseIt1[keyof UseIt1];這個過程類似排列組合:never類型的值不會出現(xiàn)在聯(lián)合類型中
// string | numbertype WithNever = string | never | number;
所以{ [K in keyof T]: ... }[keyof T]這個寫法實(shí)際上就是為了返回鍵名(準(zhǔn)備的說是鍵名組成的聯(lián)合類型)。
那么非函數(shù)類型字段也很簡單了,這里就不做展示了,下面來看可選字段OptionalKeys與必選字段RequiredKeys,先來看個小例子:
type WTFAMI1 = {} extends { prop: number } ? "Y" : "N";type WTFAMI2 = {} extends { prop?: number } ? "Y" : "N";如果能繞過來,很容易就能得出來答案。如果一時沒繞過去,也很簡單,對于前面一個情況,prop是必須的,因此空對象{}并不能滿足extends { prop: number },而對于prop為可選的情況下則可以。因此我們使用這種思路來得到可選/必選的鍵名。
{} extends Pick<T, K>,如果K是可選字段,那么就留下(OptionalKeys,如果是 RequiredKeys 就剔除)。怎么剔除?當(dāng)然是用never了。exporttype RequiredKeys<T> = {[K in keyof T]-?: {} extends Pick<T, K> ? never : K;}[keyof T];這里是剔除可選字段,那么 OptionalKeys 就是保留了:
exporttype OptionalKeys<T> = { [K in keyof T]-?: {} extends Pick<T, K> ? K : never;}[keyof T];只讀字段IMmutableKeys與非只讀字段MutableKeys的思路類似,即先獲得:
interface MutableKeys { readonlyKeys: never; notReadonlyKeys: "notReadonlyKeys";}然后再獲得不為never的字段名即可。
這里還是要表達(dá)一下對作者的敬佩,屬實(shí)巧妙啊,首先定義一個工具類型IfEqual,比較兩個類型是否相同,甚至可以比較修飾前后的情況下,也就是這里只讀與非只讀的情況。
type Equal<X, Y, A = X, B = never> = (<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y ? 1 : 2 ? A : B;不要被<T>() => T extends X ? 1 : 2干擾,可以理解為就是用于比較的包裝,這一層包裝能夠區(qū)分出來只讀與非只讀屬性。實(shí)際使用時(非只讀),我們?yōu)?X 傳入接口,為 Y 傳入去除了只讀屬性-readonly的接口,為 A 傳入字段名,B 這里我們需要的就是 never,因此可以不填。實(shí)例:
exporttype MutableKeys<T extends object> = { [P in keyof T]-?: Equal< { [Q in P]: T[P] }, { -readonly [Q in P]: T[P] }, P, never >;}[keyof T];幾個容易繞彎子的點(diǎn):
泛型 Q 在這里不會實(shí)際使用,只是映射類型的字段占位。X Y 同樣存在著 分布式條件類型, 來依次比對字段去除 readonly 前后。同樣的有:
exporttype IMmutableKeys<T extends object> = { [P in keyof T]-?: Equal< { [Q in P]: T[P] }, { -readonly [Q in P]: T[P] }, never, P >;}[keyof T];這里不是對readonly修飾符操作,而是調(diào)換條件類型的判斷語句?;谥殿愋偷?Pick 與 Omit
前面我們實(shí)現(xiàn)的 Pick 與 Omit 是基于鍵名的,假設(shè)現(xiàn)在我們需要按照值類型來做選取剔除呢?
其實(shí)很簡單,就是T[K] extends ValueType即可:
exporttype PickByValueType<T, ValueType> = Pick< T, { [Key in keyof T]-?: T[Key] extends ValueType ? Key : never }[keyof T]>;exporttype OmitByValueType<T, ValueType> = Pick< T, { [Key in keyof T]-?: T[Key] extends ValueType ? never : Key }[keyof T]>;條件類型承擔(dān)了太多...
工具類型一覽
總結(jié)下我們上面書寫的工具類型:
全量修飾接口:PartialReadonly(Immutable)MutableRequired,以及對應(yīng)的遞歸版本。裁剪接口:PickOmitPickByValueTypeOmitByValueType基于 infer:ReturnTypeParamTypePromiseType獲取指定條件字段:FunctionKeysOptionalKeysRequiredKeys ...需要注意的是,有時候單個工具類型并不能滿足你的要求,你可能需要多個工具類型協(xié)作,比如用FunctionKeys+Pick得到一個接口中類型為函數(shù)的字段。
如果你之前沒有關(guān)注過 TS 類型編程,那么可能需要一定時間來適應(yīng)思路的轉(zhuǎn)變。我的建議是,從今天開始,從現(xiàn)在的項(xiàng)目開始,從類型守衛(wèi)、泛型、最基本的Partial開始,讓你的代碼精準(zhǔn)而優(yōu)雅。
尾聲
在結(jié)尾說點(diǎn)我個人的理解吧,我認(rèn)為 TypeScript 項(xiàng)目實(shí)際上是需要經(jīng)過組織的,而不是這一個接口那一個接口,這里一個字段那里一個類型別名,更別說明明可以使用幾個工具類型輕松得到的結(jié)果卻自己重新寫了一遍接口。但很遺憾,要做到這一點(diǎn)實(shí)際上會耗費(fèi)大量精力,并且對業(yè)務(wù)帶來的實(shí)質(zhì)提升是微乎其微的(長期業(yè)務(wù)倒是還好),畢竟頁面不會因?yàn)槟愕念愋吐暶鲊?yán)謹(jǐn)環(huán)環(huán)相扣就 PVUV 暴增。我目前的階段依然停留在尋求開發(fā)的效率和質(zhì)量間尋求平衡,目前的結(jié)論:多寫 TS,腳本/爬蟲/配置/demo,能用TS的就用TS寫,寫到如臂使指,你的效率就會 upu
參考資料
[1]ts-node: https://github.com/TypeStrong/ts-node
[2]#1565: https://github.com/remy/nodemon/issues/1565#issuecomment-490429334
[3]走近 MidwayJS:初識 TS 裝飾器與 IoC 機(jī)制: https://juejin.im/post/6859314697204662279
[4]TypeScript3.7: https://devblogs.microsoft.com/typescript/announcing-typescript-3-7/
[5]top-10-javascript-errors: https://rollbar.com/blog/top-10-javascript-errors/
[6]尤大的知乎回答: https://www.zhihu.com/search?type=content&q=ts%20never
[7]TypeScript 4.1: https://devblogs.microsoft.com/typescript/announcing-typescript-4-1/
[8]40336: https://github.com/microsoft/TypeScript/pull/40336
[9]utility-types: https://github.com/piotrwitek/utility-types
[10]react-redux-typescript-guide: https://github.com/piotrwitek/react-redux-typescript-guide
[11]typesafe-actions: https://github.com/piotrwitek/typesafe-actions
[12]type-fest: https://github.com/sindresorhus/type-fest
The End
歡迎自薦投稿到《前端技術(shù)江湖》,如果你覺得這篇內(nèi)容對你挺有啟發(fā),記得點(diǎn)個 「在看」哦
舉報(bào)/反饋
引用: https://baijiahao.baidu.com/s?id=1705957833047970461&wfr=spider&for=pc