TypeScript 的另一面:類型編程
前言
作為前端開發(fā)的趨勢(shì)之一,TypeScript 正在為越來越多的開發(fā)者所喜愛,從大的方面來說,幾乎九成的框架與工具庫都以其寫就(或者就是類似的類型方案,如 Flow);而從小的方面來說,即使是寫個(gè)配置文件(如 vite 的配置文件)或者小腳本(感謝 ts-node),TypeScript 也是一大助力。一樣事物不可能做到每個(gè)人都喜歡,如 nodemon 的作者 Remy Sharp 就曾表示自己從來沒有使用過 TS(見#1565(https://github.com/remy/nodemon/issues/1565#issuecomment-490429334)),在以后也不會(huì)去學(xué)習(xí) TS,這可能是因?yàn)檎Z言習(xí)慣的問題。而通常阻礙新人上手 TypeScript 的還有另外一座大山:學(xué)習(xí)成本高。
在學(xué)習(xí) TypeScript 的開始階段,很多同學(xué)對(duì)它是又愛又恨的,離不開它的類型提示和工程能力,卻又經(jīng)常為類型錯(cuò)誤困擾,最后不得不用個(gè) any 了事,這樣的情況多了,TypeScript 就慢慢寫成了 AnyScript...
這個(gè)問題的罪魁禍?zhǔn)灼鋵?shí)就是部分同學(xué)在開始學(xué)習(xí) TypeScript 時(shí),要么是被逼上梁山,在一片空白的情況下接手 TS 項(xiàng)目,要么是不知道如何學(xué)習(xí),那么良心的官方文檔不看,看了幾篇相關(guān)文章就覺得自己會(huì)了,最后遇到問題還是一頭霧水。
這篇文章就是為了解決這后者的問題,嘗試專注于 TypeScript 的類型編程部分(TS 還有幾個(gè)部分?請(qǐng)看下面的解釋),從最基礎(chǔ)的泛型開始,到索引、映射、條件等類型,再到 is、in、infer 等關(guān)鍵字,最后是壓軸的工具類型。打開你的 IDE,跟著筆者一節(jié)節(jié)的敲完代碼,幫助你的 TypeScript 水平邁上新的臺(tái)階。
需要注意的是,本文并非 TypeScript 入門文章,并不適用于對(duì) TypeScript 暫時(shí)沒有任何經(jīng)驗(yàn)的同學(xué)。如果你仍處于新手期,筆者在這里推薦 xcatliu 的《TypeScript 入門教程》(https://ts.xcatliu.com/) 以及 《官方文檔》(https://www.typescriptlang.org/zh/),從我個(gè)人的經(jīng)驗(yàn)來看,你可以在初期閱讀入門教程,并在感到困惑時(shí)前往官方文檔對(duì)應(yīng)部分查閱。
在完成 TypeScript 的基礎(chǔ)入門后,歡迎再次回到本篇文章。
TypeScript = 類型編程 + ES 提案
筆者通常將 TypeScript 劃分成兩個(gè)部分:
預(yù)實(shí)現(xiàn)的 ES 提案,如 裝飾器、 可選鏈
?.、空值合并運(yùn)算符??(和可選鏈一起在 TypeScript3.7 中引入)、類的私有成員private等。除了部分極端不穩(wěn)定的語法(說的就是你,裝飾器)以外,大部分的 TS 實(shí)現(xiàn)實(shí)際上就是未來的 ES 語法。嚴(yán)謹(jǐn)?shù)膩碚f,現(xiàn)在的 ES 版本裝飾器和 TS 版本裝飾器已經(jīng)是兩個(gè)東西了,筆者先前在 《走近 MidwayJS:初識(shí) TS 裝飾器與 IoC 機(jī)制》(https://juejin.im/post/6859314697204662279) 這篇文章中介紹了一些關(guān)于 TS 裝飾器的歷史,有興趣的同學(xué)不妨一讀。
對(duì)于這一部分來說,無論你先前是只有 JavaScript 這門語言的使用經(jīng)驗(yàn),還是有過 Java、C#的使用經(jīng)歷,都能非常快速地上手,畢竟主要還是語法糖為主嘛。當(dāng)然,這也是實(shí)際開發(fā)中使用最多的部分,畢竟和另一部分:類型編程比起來,還是這一部分更接地氣。
類型編程,從一個(gè)簡(jiǎn)簡(jiǎn)單單的
interface,到看起來挺高級(jí)的T extends SomeType,再到各種不明覺厲的工具類型Partial、Required等,這些都屬于類型編程的范疇。這一部分對(duì)代碼實(shí)際的功能層面沒有任何影響,即使你一行代碼十個(gè) any,遇到類型錯(cuò)誤就@ts-ignore(類似于@eslint-ignore,將會(huì)禁用掉下一行的類型檢查),甚至直接開啟--transpileOnly(這一選項(xiàng)會(huì)禁用掉 TS 編譯器的類型檢查能力,僅編譯代碼,會(huì)獲得更快的編譯速度·),也不會(huì)影響你代碼本身的邏輯。然而,這也就是類型編程一直不受到太多重視的原因:相比于語法,它會(huì)帶來許多額外的代碼量(類型定義代碼甚至可能超過業(yè)務(wù)代碼量)等問題。而且實(shí)際業(yè)務(wù)中并不會(huì)需要多么苛刻的類型定義,通常只會(huì)對(duì)接口數(shù)據(jù)、應(yīng)用狀態(tài)流等進(jìn)行定義,通常是底層框架類庫才會(huì)需要大量的類型編程代碼。
如果說,上一部分讓你寫的代碼更甜,那么這一部分,最重要的作用是讓你的代碼變得更優(yōu)雅健壯(是的,優(yōu)雅和健壯并不沖突)。如果你所在的團(tuán)隊(duì)使用 Sentry 這一類監(jiān)控平臺(tái),對(duì)于 JS 代碼來說最常見的錯(cuò)誤就是
Cannot read property 'xxx' of undefined、undefined is not a function這種(見《top-10-javascript-errors》(https://rollbar.com/blog/top-10-javascript-errors/)),雖然即使是 TS 也不可能把這個(gè)錯(cuò)誤直接完全抹消,但也能解決十之八九了。
好了,做了這么多鋪墊,是時(shí)候開始進(jìn)入正題了,本文的章節(jié)分布如下,如果你已經(jīng)有部分前置知識(shí)的基礎(chǔ)(如泛型),可以直接跳過。
類型編程的基礎(chǔ):泛型 類型守衛(wèi)與 is、in 關(guān)鍵字 索引類型與映射類型 條件類型、分布式條件類型 infer 關(guān)鍵字 工具類型 TypeScript 4.x 新特性
泛型
之所以上來就放泛型,是因?yàn)樵?TypeScript 的整個(gè)類型編程體系中,它是最基礎(chǔ)的那部分,所有的進(jìn)階類型都基于它書寫。就像編程時(shí)我們不能沒有變量,類型編程中的變量就是泛型。
假設(shè)我們有這么一個(gè)函數(shù):
function?foo(args:?unknown):?unknown?{?...?}
如果它接收一個(gè)字符串,返回這個(gè)字符串的部分截取。 如果接收一個(gè)數(shù)字,返回這個(gè)數(shù)字的 n 倍。 如果接收一個(gè)對(duì)象,返回鍵值被更改過的對(duì)象(鍵名不變)。
上面這些場(chǎng)景有一個(gè)共同點(diǎn),即函數(shù)的返回值與入?yún)⑹峭活愋?
如果在這里要獲得精確地類型定義,應(yīng)該怎么做?
把 unknown替換為string | number | object?但這樣代表的意思是這個(gè)函數(shù)接受任何值,其返回類型都可能是 string / number / object,雖然有了類型定義,但完全稱不上是精確。
別忘記我們需要的是 入?yún)⑴c返回值類型相同 的效果。這個(gè)時(shí)候泛型就該登場(chǎng)了,我們先用一個(gè)泛型收集參數(shù)的類型值,再將其作為返回值,就像這樣:
function?foo<T>(arg:?T):?T?{
??return?arg;
}
這樣在我們使用 foo 函數(shù)時(shí),編輯器就能實(shí)時(shí)根據(jù)我們傳入的參數(shù)確定此函數(shù)的返回值了。就像編程時(shí),程序中變量的值會(huì)在其運(yùn)行時(shí)才被確定,泛型的值(類型)也是在方法被調(diào)用、類被實(shí)例化等類似的執(zhí)行過程實(shí)際發(fā)生時(shí)才會(huì)被確定的。
泛型使得代碼段的類型定義易于重用(比如后續(xù)又多了一種接收 boolean 返回 boolean 的函數(shù)實(shí)現(xiàn)),并提升了靈活性與嚴(yán)謹(jǐn)性。
另外,你可能曾經(jīng)見過 Array Map 這樣的使用方式,通常我們將上面例子中 T 這樣的未賦值形式稱為 類型參數(shù)變量 或者說 泛型類型,而將 Array 這樣已經(jīng)實(shí)例化完畢的稱為 實(shí)際類型參數(shù) 或者是 參數(shù)化類型。
通常泛型只會(huì)使用單個(gè)字母。如 T U K V S等。我的推薦做法是在項(xiàng)目達(dá)到一定復(fù)雜度后,使用帶有具體意義的泛型變量聲明,如
BasicBusinessType這種形式。
foo<string>("linbudu");
const?[count,?setCount]?=?useState<number>(1);
上面的例子也可以不指定,因?yàn)?TS 會(huì)自動(dòng)推導(dǎo)出泛型的實(shí)際類型,在部分 Lint 規(guī)則中,實(shí)際上也不推薦添加能夠被自動(dòng)推導(dǎo)出的類型值。
泛型在箭頭函數(shù)下的書寫:
const?foo?=?(arg:?T)?=>?arg;
如果你在 TSX 文件中這么寫,
可能會(huì)被識(shí)別為 JSX 標(biāo)簽,因此需要顯式告知編譯器:const?foo?=?extends?SomeBasicType>(arg:?T)?=>?arg;
除了用在函數(shù)中,泛型也可以在類中使用:
class?Foo?{
??constructor(public?arg1:?T,?public?arg2:?U)?{}
??public?method():?T?{
????return?this.arg1;
??}
}
單獨(dú)對(duì)于泛型的介紹就到這里(因?yàn)閱渭兊闹v泛型實(shí)在沒有什么好講的),在接下來的進(jìn)階類型篇章中,我們會(huì)講解更多泛型的使用。
類型守衛(wèi)、is in關(guān)鍵字
我們來從相對(duì)簡(jiǎn)單直觀的知識(shí)點(diǎn):類型守衛(wèi) 開始,由淺入深的了解基于泛型的類型編程。
假設(shè)有這么一個(gè)字段,它可能字符串也可能是數(shù)字:
numOrStrProp:?number?|?string;
現(xiàn)在在使用時(shí),你想將這個(gè)字段的聯(lián)合類型縮小范圍,比如精確到string,你可能會(huì)這么寫:
export?const?isString?=?(arg:?unknown):?boolean?=>?typeof?arg?===?"string";
看看這么寫的效果:
function?useIt(numOrStr:?number?|?string)?{
??if?(isString(numOrStr))?{
????console.log(numOrStr.length);
??}
}

看起來 isString 函數(shù)并沒有起到縮小類型范圍的作用,參數(shù)依然是聯(lián)合類型。這個(gè)時(shí)候就該使用 is 關(guān)鍵字了:
export?const?isString?=?(arg:?unknown):?arg?is?string?=>
??typeof?arg?===?"string";
這個(gè)時(shí)候再去使用,就會(huì)發(fā)現(xiàn)在 isString(numOrStr) 為 true后,numOrStr的類型就被縮小到了string。這只是以原始類型為成員的聯(lián)合類型,我們完全可以擴(kuò)展到各種場(chǎng)景上,先看一個(gè)簡(jiǎn)單的假值判斷:
export?type?Falsy?=?false?|?""?|?0?|?null?|?undefined;
export?const?isFalsy?=?(val:?unknown):?val?is?Falsy?=>?!val;
這應(yīng)該是我日常用的最多的類型別名之一了,類似的,還有 isPrimitive 、isFunction這樣的類型守衛(wèi)。
而使用 in 關(guān)鍵字,我們可以進(jìn)一步收窄類型(Type Narrowing),思考下面這個(gè)例子,要如何將 " 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),它遍歷對(duì)象的屬性名,而 in 關(guān)鍵字也是一樣,它能夠判斷一個(gè)屬性是否為對(duì)象所擁有:
function?useIt(arg:?A?|?B):?void?{
??'a'?in?arg???arg.useA()?:?arg.useB();
}
如果參數(shù)中存在a屬性,由于A、B兩個(gè)類型的交集并不包含a,所以這樣能立刻收窄類型判斷到 A 身上。
由于A、B兩個(gè)類型的交集并不包含 a 這個(gè)屬性,所以這里的 in 判斷會(huì)精確地將類型對(duì)應(yīng)收窄到三元表達(dá)式的前后。即 A 或者 B。
再看一個(gè)使用字面量類型作為類型守衛(wèi)的例子:
interface?IBoy?{
??name:?"mike";
??gf:?string;
}
interface?IGirl?{
??name:?"sofia";
??bf:?string;
}
function?getLover(child:?IBoy?|?IGirl):?string?{
??if?(child.name?===?"mike")?{
????return?child.gf;
??}?else?{
????return?child.bf;
??}
}
關(guān)于字面量類型
literal types,它是對(duì)類型的進(jìn)一步限制,比如你的狀態(tài)碼只可能是 0/1/2,那么你就可以寫成status: 0 | 1 | 2的形式,而不是用一個(gè)number來表達(dá)。字面量類型包括 字符串字面量、數(shù)字字面量、布爾值字面量,以及4.1版本引入的模板字面量類型(這個(gè)我們會(huì)在后面展開講解)。
字符串字面量,常見如 mode: "dev" | "prod"。布爾值字面量通常與其他字面量類型混用,如 open: true | "none" | "chrome"。這一類細(xì)碎的基礎(chǔ)知識(shí)會(huì)被穿插在文中各個(gè)部分進(jìn)行講解,以此避免單獨(dú)講解時(shí)缺少特定場(chǎng)景讓相關(guān)概念顯得過于單調(diào)。
基于字段區(qū)分接口
我在日常經(jīng)常看到有同學(xué)在問類似的問題:登錄與未登錄下的用戶信息是完全不同的接口,或者是
之前有個(gè)小哥問過一個(gè)問題,我想很多用 TS 寫接口的小伙伴可能都遇到過,即登錄與未登錄下的用戶信息是完全不同的接口(或者是類似的,需要基于屬性、字段來區(qū)分不同接口),其實(shí)也可以使用 in關(guān)鍵字 解決:
interface?ILogInUserProps?{
??isLogin:?boolean;
??name:?string;
}
interface?IUnLoginUserProps?{
??isLogin:?boolean;
??from:?string;
}
type?UserProps?=?ILogInUserProps?|?IUnLoginUserProps;
function?getUserInfo(user:?ILogInUserProps?|?IUnLoginUserProps):?string?{
??return?'name'?in?user???user.name?:?user.from;
}
或者通過字面量類型:
interface?ICommonUserProps?{
??type:?"common",
??accountLevel:?string
}
interface?IVIPUserProps?{
??type:?"vip";
??vipLevel:?string;
}
type?UserProps?=?ICommonUserProps?|?IVIPUserProps;
function?getUserInfo(user:?ICommonUserProps?|?IVIPUserProps):?string?{
??return?user.type?===?"common"???user.accountLevel?:?user.vipLevel;
}
同樣的思路,還可以使用instanceof來進(jìn)行實(shí)例的類型守衛(wèi),建議聰明的你動(dòng)手嘗試下。
索引類型與映射類型
索引類型
在閱讀這一部分前,你需要做好思維轉(zhuǎn)變的準(zhǔn)備,需要真正認(rèn)識(shí)到 類型編程實(shí)際也是編程,因?yàn)閺倪@里開始,我們就將真正將泛型作為變量進(jìn)行各種花式操作了。
就像你寫業(yè)務(wù)代碼的時(shí)候常常會(huì)遍歷一個(gè)對(duì)象,而在類型編程中我們也會(huì)經(jīng)常遍歷一個(gè)接口。因此,你完全可以將一部分編程思路復(fù)用過來。首先實(shí)現(xiàn)一個(gè)簡(jiǎn)單的函數(shù),它返回一個(gè)對(duì)象的某個(gè)鍵值:
//?假設(shè)key是obj鍵名
function?pickSingleValue(obj,?key)?{
??return?obj[key];
}
要為其進(jìn)行類型定義的話,有哪些需要定義的地方?
參數(shù) obj參數(shù) key返回值
這三樣之間存在著一定關(guān)聯(lián):
key必然是obj中的鍵值名之一,且一定為string類型(通常我們只會(huì)使用字符串作為對(duì)象鍵名)返回的值一定是 obj 中的鍵值
因此我們初步得到這樣的結(jié)果:
function?pickSingleValue<T>(obj:?T,?key:?keyof?T)?{
??return?obj[key];
}
keyof 是索引類型查詢的語法, 它會(huì)返回后面跟著的類型參數(shù)的鍵值組成的字面量聯(lián)合類型,舉個(gè)例子:
interface?foo?{
??a:?number;
??b:?string;
}
type?A?=?keyof?foo;?//?"a"?|?"b"
是不是就像 Object.keys() 一樣?區(qū)別就在于它返回的是聯(lián)合類型。
聯(lián)合類型
Union Type通常使用|語法,代表多個(gè)可能的取值,實(shí)際上在最開始我們就已經(jīng)使用過了。聯(lián)合類型最主要的使用場(chǎng)景還是 條件類型 部分,這在后面會(huì)有一個(gè)完整的章節(jié)來進(jìn)行講解。
還少了返回值,如果你此前沒有接觸過此類語法,應(yīng)該會(huì)卡住,我們先聯(lián)想下for...in語法,遍歷對(duì)象時(shí)我們可能會(huì)這么寫:
const?fooObj?=?{?a:?1,?b:?"1"?};
for?(const?key?in?fooObj)?{
??console.log(key);
??console.log(fooObj[key]);
}
和上面的寫法一樣,我們拿到了 key,就能拿到對(duì)應(yīng)的 value,那么 value 的類型就更簡(jiǎn)單了:
function?pickSingleValue<T>(obj:?T,?key:?keyof?T):?T[keyof?T]?{
??return?obj[key];
}
這一部分可能不好一步到位理解,解釋下:
interface?T?{
a:?number;
b:?string;
}
type?TKeys?=?keyof?T;?//?"a"?|?"b"
type?PropAType?=?T["a"];?//?number你用鍵名可以取出對(duì)象上的鍵值,自然也就可以取出接口上的鍵值(也就是類型)啦~
但這種寫法很明顯有可以改進(jìn)的地方:keyof出現(xiàn)了兩次,以及泛型 T 其實(shí)應(yīng)該被限制為對(duì)象類型。對(duì)于第一點(diǎn),就像我們平時(shí)編程會(huì)做的那樣:用一個(gè)變量把多處出現(xiàn)的存起來,記得,在類型編程里,泛型就是變量。
function?pickSingleValue<T?extends?object,?U?extends?keyof?T>(
??obj:?T,
??key:?U
):?T[U]?{
??return?obj[key];
}
這里又出現(xiàn)了新東西
extends... 它是啥?你可以暫時(shí)把T extends object理解為T 被限制為對(duì)象類型,U extends keyof T理解為 泛型 U 必然是泛型 T 的鍵名組成的聯(lián)合類型(以字面量類型的形式,比如T這個(gè)對(duì)象的鍵名包括a b c,那么U的取值只能是"a" "b" "c"之一,即"a" | "b" | "c")。具體細(xì)節(jié)我們會(huì)在 條件類型 一章講到。
假設(shè)現(xiàn)在不只要取出一個(gè)值了,我們要取出一系列值,即參數(shù) 2 將是一個(gè)數(shù)組,成員均為參數(shù) 1 的鍵名組成:
function?pick<T?extends?object,?U?extends?keyof?T>(obj:?T,?keys:?U[]):?T[U][]?{
??return?keys.map((key)?=>?obj[key]);
}
//?pick(obj,?['a',?'b'])
有兩個(gè)重要變化:
keys: U[]我們知道 U 是 T 的鍵名組成的聯(lián)合類型,那么要表示一個(gè)內(nèi)部元素均是 T 鍵名的數(shù)組,就可以使用這種方式,具體的原理請(qǐng)參見下文的 分布式條件類型 章節(jié)。T[U][]它的原理實(shí)際上和上面一條相同,首先是T[U],代表參數(shù)1的鍵值(就像Object[Key]),我認(rèn)為它是一個(gè)很好地例子,表現(xiàn)了 TS 類型編程的組合性,你不感覺這種寫法就像搭積木一樣嗎?
索引簽名 Index Signature
在JavaScript中,我們通常使用 arr[1] 的方式索引數(shù)組,使用 obj[key] 的方式索引對(duì)象。說白了,索引就是你獲取一個(gè)對(duì)象成員的方式,而在類型編程中,索引簽名用于快速建立一個(gè)內(nèi)部字段類型相同的接口,如
interface?Foo?{
??[keys:?string]:?string;
}
那么接口 Foo 實(shí)際上等價(jià)于一個(gè)鍵值全部為 string 類型,不限制成員的接口。
等同于
Record,見 工具類型。
值得注意的是,由于 JS 可以同時(shí)通過數(shù)字與字符串訪問對(duì)象屬性,因此keyof Foo的結(jié)果會(huì)是string | number。
const?o:?Foo?=?{
1:?"蕪湖!",
};
o[1]?===?o["1"];?//?true
但是一旦某個(gè)接口的索引簽名類型為number,那么使用它的對(duì)象就不能再通過字符串索引訪問,如o['1'],將會(huì)拋出錯(cuò)誤, 元素隱式具有 "any" 類型,因?yàn)樗饕磉_(dá)式的類型不為 "number"。
映射類型 Mapped Types
在開始映射類型前,首先想想 JavaScript 中數(shù)組的 map 方法,通過使用map,我們從一個(gè)數(shù)組按照既定的映射關(guān)系獲得一個(gè)新的數(shù)組。在類型編程中,我們則會(huì)從一個(gè)類型定義(包括但不限于接口、類型別名)映射得到一個(gè)新的類型定義。通常會(huì)在舊有類型的基礎(chǔ)上進(jìn)行改造,如:
修改原接口的鍵值類型 為原接口鍵值類型新增修飾符,如 readonly與 可選?
從一個(gè)簡(jiǎn)單場(chǎng)景入手:
interface?A?{
??a:?boolean;
??b:?string;
??c:?number;
??d:?()?=>?void;
}
現(xiàn)在我們有個(gè)需求,實(shí)現(xiàn)一個(gè)接口,它的字段與接口 A 完全相同,但是其中的類型全部為 string,你會(huì)怎么做?直接重新聲明一個(gè)然后手寫嗎?這樣就很離譜了,我們可是機(jī)智的程序員。
如果把接口換成對(duì)象再想想,假設(shè)要拷貝一個(gè)對(duì)象(假設(shè)沒有嵌套,不考慮引用類型變量存放地址),常用的方式是首先 new 一個(gè)新的空對(duì)象,然后遍歷原先對(duì)象的鍵值對(duì)來填充新對(duì)象。而接口其實(shí)也一樣:
type?StringifyA?=?{
??[K?in?keyof?T]:?string;
};
是不是很熟悉?重要的就是這個(gè)in操作符,你完全可以把它理解為 for...in/for...of 這種遍歷的思路,獲取到鍵名之后,鍵值就簡(jiǎn)單了,所以我們可以很容易的拷貝一個(gè)新的類型別名出來。
type?ClonedA?=?{
??[K?in?keyof?T]:?T[K];
};
掌握這種思路,其實(shí)你已經(jīng)接觸到一些工具類型的底層實(shí)現(xiàn)了:
你可以把工具類型理解為你平時(shí)放在 utils 文件夾下的公共函數(shù),提供了對(duì)公用邏輯(在這里則是類型編程邏輯)的封裝,比如上面的兩個(gè)類型接口就是。關(guān)于更多工具類型,參考 工具類型 一章。
先寫個(gè)最常用的 Partial嘗嘗鮮,工具類型的詳細(xì)介紹我們會(huì)在專門的章節(jié)展開:
//?將接口下的字段全部變?yōu)榭蛇x的
type?Partial?=?{
??[K?in?keyof?T]?:?T[k];
};
key?: value意為這一字段是可選的,在大部分情況下等同于key: value | undefined。
條件類型 Conditional Types
在編程中遇到條件判斷,我們常用 If 語句與三元表達(dá)式實(shí)現(xiàn),我個(gè)人偏愛后者,即使是:
if?(condition)?{
??execute()
}
這種沒有 else 的 If 語句,我也習(xí)慣寫成:
condition???execute()?:?void?0;
而 條件類型 的語法,實(shí)際上就是三元表達(dá)式,看一個(gè)最簡(jiǎn)單的例子:
T?extends?U???X?:?Y
如果你覺得這里的 extends 不太好理解,可以暫時(shí)簡(jiǎn)單理解為 U 中的屬性在 T 中都有。
為什么會(huì)有條件類型?可以看到 條件類型 通常是和 泛型 一同使用的,聯(lián)想到泛型的使用場(chǎng)景以及值得延遲推斷,我想你應(yīng)該明白了些什么。對(duì)于類型無法即時(shí)確定的場(chǎng)景,使用 條件類型 來在運(yùn)行時(shí)動(dòng)態(tài)的確定最終的類型(運(yùn)行時(shí)可能不太準(zhǔn)確,或者可以理解為,你提供的函數(shù)被他人使用時(shí),根據(jù)他人使用時(shí)傳入的參數(shù)來動(dòng)態(tài)確定需要被滿足的類型約束)。
類比到編程語句中,其實(shí)就是根據(jù)條件判斷來動(dòng)態(tài)的賦予變量值:
let?unknownVar:?string;
unknownVar?=?condition???"淘系前端"?:?"淘寶FED";
type?LiteralType?=?T?extends?string???"foo"?:?"bar";
條件類型理解起來其實(shí)也很直觀,唯一需要有一定理解成本的就是 何時(shí)條件類型系統(tǒng)會(huì)收集到足夠的信息來確定類型,也就是說,條件類型有時(shí)不會(huì)立刻完成判斷,比如工具庫提供的函數(shù),需要用戶在使用時(shí)傳入?yún)?shù)才會(huì)完成 條件類型 的判斷。
在了解這一點(diǎn)前,我們先來看看條件類型常用的一個(gè)場(chǎng)景:泛型約束,實(shí)際上就是我們上面 索引類型 的例子:
function?pickSingleValue<T?extends?object,?U?extends?keyof?T>(
??obj:?T,
??key:?U
):?T[U]?{
??return?obj[key];
}
這里的 T extends object 與 U extends keyof T 都是泛型約束,分別將 T 約束為對(duì)象類型 和 將 U 約束為 T 鍵名的字面量聯(lián)合類型(不記得了?提示:1 | 2 | 3)。我們通常使用泛型約束來 收窄類型約束,簡(jiǎn)單的說,泛型本身是來者不拒的,所有類型都能被 顯式傳入(如 Array) 或者 隱式推導(dǎo) (如 foo(1)),這樣其實(shí)不是我們想要的,就像我們有時(shí)會(huì)檢測(cè)函數(shù)的參數(shù):
function?checkArgFirst(arg){
??if(typeof?arg?!==?"number"){
????throw?new?Error("arg?must?be?number?type!")
??}
}
在 TS 中,我們通過泛型約束,要求傳入的泛型只能是固定的類型,如 T extends {} 約束泛型至對(duì)象類型,T extends number | string將泛型約束至數(shù)字與字符串類型。
以一個(gè)使用條件類型作為函數(shù)返回值類型的例子:
declare?function?strOrNum<T?extends?boolean>(
??x:?T
):?T?extends?true???string?:?number;
在這種情況下,條件類型的推導(dǎo)就會(huì)被延遲,因?yàn)榇藭r(shí)類型系統(tǒng)沒有足夠的信息來完成判斷。
只有給出了所需信息(在這里是入?yún)?x 的類型),才可以完成推導(dǎo)。
const?strReturnType?=?strOrNum(true);
const?numReturnType?=?strOrNum(false);
同樣的,就像三元表達(dá)式可以嵌套,條件類型也可以嵌套,如果你看過一些框架源碼,也會(huì)發(fā)現(xiàn)其中存在著許多嵌套的條件類型,無他,條件類型可以將類型約束收攏到非常窄的范圍內(nèi),提供精確的條件類型,如:
type?TypeName?=?T?extends?string??
????"string"??
??:?T?extends?number??
????"number"??
??:?T?extends?boolean??
????"boolean"??
??:?T?extends?undefined??
????"undefined"??
??:?T?extends?Function??
????"function"??
??:?"object";
分布式條件類型 Distributive Conditional Types
分布式條件類型實(shí)際上不是一種特殊的條件類型,而是其特性之一(所以說條件類型的分布式特性更為準(zhǔn)確)。我們直接先上概念: 對(duì)于屬于裸類型參數(shù)的檢查類型,條件類型會(huì)在實(shí)例化時(shí)期自動(dòng)分發(fā)到聯(lián)合類型上。
原文: Conditional types in which the checked type is a naked type parameter are called distributive conditional types. Distributive conditional types are automatically distributed over union types during instantiation
先提取幾個(gè)關(guān)鍵詞,然后我們?cè)偻ㄟ^例子理清這個(gè)概念:
裸類型參數(shù)(類型參數(shù)即泛型,見文章開頭的泛型章節(jié)介紹) 實(shí)例化 分發(fā)到聯(lián)合類型
//?使用上面的TypeName類型別名
//?"string"?|?"function"
type?T1?=?TypeName<string?|?(()?=>?void)>;
//?"string"?|?"object"
type?T2?=?TypeName<string?|?string[]>;
//?"object"
type?T3?=?TypeName<string[]?|?number[]>;
我們發(fā)現(xiàn)在上面的例子里,條件類型的推導(dǎo)結(jié)果都是聯(lián)合類型(T3 實(shí)際上也是,只不過因?yàn)榻Y(jié)果相同所以被合并了),并且其實(shí)就是類型參數(shù)被依次進(jìn)行條件判斷后,再使用|組合得來的結(jié)果。
是不是 get 到了一點(diǎn)什么?上面的例子中泛型都是裸露著的,如果被包裹著,其條件類型判斷結(jié)果會(huì)有什么變化嗎?我們?cè)倏戳硪粋€(gè)例子:
type?Naked?=?T?extends?boolean???"Y"?:?"N";
type?Wrapped?=?[T]?extends?[boolean]???"Y"?:?"N";
//?"N"?|?"Y"
type?Distributed?=?Naked<number?|?boolean>;
//?"N"
type?NotDistributed?=?Wrapped<number?|?boolean>;
其中,Distributed類型別名,其類型參數(shù)(
number | boolean)會(huì)正確的分發(fā),即先分發(fā)到
Naked,再進(jìn)行判斷,所以結(jié)果是| Naked "N" | "Y"。而 NotDistributed 類型別名,第一眼看上去感覺TS應(yīng)該會(huì)自動(dòng)按數(shù)組進(jìn)行分發(fā),結(jié)果應(yīng)該也是
"N" | "Y"?但實(shí)際上,它的類型參數(shù)(number | boolean)不會(huì)有分發(fā)流程,直接進(jìn)行[number | boolean] extends [boolean]的判斷,所以結(jié)果是"N"。
現(xiàn)在我們可以來講講這幾個(gè)概念了:
裸類型參數(shù),沒有額外被
[]包裹過的,就像被數(shù)組包裹后就不能再被稱為裸類型參數(shù)。實(shí)例化,其實(shí)就是條件類型的判斷過程,就像我們前面說的,條件類型需要在收集到足夠的推斷信息之后才能進(jìn)行這個(gè)過程。在這里兩個(gè)例子的實(shí)例化過程實(shí)際上是不同的,具體會(huì)在下一點(diǎn)中介紹。
分發(fā)到聯(lián)合類型:
對(duì)于 TypeName,它內(nèi)部的類型參數(shù) T 是沒有被包裹過的,所以
TypeName會(huì)被分發(fā)為void)> TypeName,然后再次進(jìn)行判斷,最后分發(fā)為| TypeName<(() => void)> "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)
//?使用[]包裹后,不會(huì)進(jìn)行額外的分發(fā)邏輯。
[A?|?B?|?C]?extends?[T]???X?:?Y一句話概括:沒有被
[]額外包裝的聯(lián)合類型參數(shù),在條件類型進(jìn)行判定時(shí)會(huì)將聯(lián)合類型分發(fā),分別進(jìn)行判斷。
這兩種行為沒有好壞之分,區(qū)別只在于是否進(jìn)行聯(lián)合類型的分發(fā),如果你需要走分布式條件類型,那么注意保持你的類型參數(shù)為裸類型參數(shù)。如果你想避免這種行為,那么使用 [] 包裹你的類型參數(shù)即可(注意在 extends 關(guān)鍵字的兩側(cè)都需要)。
infer 關(guān)鍵字
在條件類型中,我們展示了如何通過條件判斷來延遲確定類型,但僅僅使用條件類型也有一定不足:它無法從條件上得到類型信息。舉例來說,T extends Array這一例子,我們不能從作為條件的 Array 中獲取到 PrimitiveType 的實(shí)際類型。
而這樣的場(chǎng)景又是十分常見的,如獲取函數(shù)返回值的類型、拆箱Promise / 數(shù)組等,因此這一節(jié)我們來介紹下 infer 關(guān)鍵字。
infer是 inference 的縮寫,通常的使用方式是用于修飾作為類型參數(shù)的泛型,如: infer R,R表示 待推斷的類型。通常 infer不會(huì)被直接使用,而是與條件類型一起,被放置在底層工具類型中。如果說條件類型提供了延遲推斷的能力,那么加上 infer 就是提供了基于條件進(jìn)行延遲推斷的能力。
看一個(gè)簡(jiǎn)單的例子,用于獲取函數(shù)返回值類型的工具類型ReturnType:
const?foo?=?():?string?=>?{
??return?"linbudu";
};
type?ReturnType?=?T?extends?(...args:?any[])?=>?infer?R???R?:?never;
//?string
type?FooReturnType?=?ReturnType<typeof?foo>;
(...args: any[]) => infer R是一個(gè)整體,這里函數(shù)的返回值類型的位置被infer R占據(jù)了。當(dāng)
ReturnType被調(diào)用,類型參數(shù) T 、R 被顯式賦值(T為typeof foo,infer R被整體賦值為string,即函數(shù)的返回值類型),如果 T 滿足條件類型的約束,就返回 infer 完畢的R 的值,在這里 R 即為函數(shù)的返回值實(shí)際類型。實(shí)際上為了嚴(yán)謹(jǐn),應(yīng)當(dāng)約束泛型T為函數(shù)類型,即:
//?第一個(gè)?extends?約束可傳入的泛型只能為函數(shù)類型
//?第二個(gè)?extends?作為條件判斷
type?ReturnTypeextends?(...args:?any[])?=>?any>?=?T?extends?(...args:?any[])?=>?infer?R???R?:?never;
infer的使用思路可能不是那么好習(xí)慣,我們可以用前端開發(fā)中常見的一個(gè)例子類比,頁面初始化時(shí)先顯示占位交互,像 Loading / 骨架屏,在請(qǐng)求返回后再去渲染真實(shí)數(shù)據(jù)。infer也是這個(gè)思路,類型系統(tǒng)在獲得足夠的信息(通常來自于條件的延遲推斷)后,就能將 infer 后跟隨的類型參數(shù)推導(dǎo)出來,最后通常會(huì)返回這個(gè)推導(dǎo)結(jié)果。
類似的,借著這個(gè)思路我們還可以獲得函數(shù)入?yún)㈩愋汀㈩惖臉?gòu)造函數(shù)入?yún)㈩愋汀⑸踔?Promise 內(nèi)部的類型等,這些工具類型我們會(huì)在后面講到。
另外,對(duì)于 TS 中函數(shù)重載的情況,使用 infer (如上面的 ReturnType)不會(huì)為所有重載執(zhí)行推導(dǎo)過程,只有最后一個(gè)重載(因?yàn)橐话銇碚f最后一個(gè)重載通常是最廣泛的情況)會(huì)被使用。
工具類型 Tool Type
這一章應(yīng)該是本文“性價(jià)比”最高的一部分了,因?yàn)榧词鼓阍陂喿x完這部分后,還是不太懂這些工具類型是如何實(shí)現(xiàn)的,也不影響你把它用的恰到好處,就像 Lodash 不會(huì)要求你對(duì)每個(gè)使用的函數(shù)都熟知原理一樣。
這一部分包括 TS 內(nèi)置工具類型 與社區(qū)的 擴(kuò)展工具類型,我個(gè)人推薦在完成學(xué)習(xí)后挑選一部分工具類型記錄下來,比如你覺得比較有價(jià)值、現(xiàn)有或者未來業(yè)務(wù)可能會(huì)使用,或者僅僅是覺得很好玩的工具類型,并在自己的項(xiàng)目里新建一個(gè).d.ts文件(或是 /utils/tool-types.ts 這樣)存儲(chǔ)它。
在繼續(xù)閱讀前,最好確保你掌握了上面的知識(shí),它們是工具類型的基礎(chǔ)。
內(nèi)置工具類型
在上面我們已經(jīng)實(shí)現(xiàn)了內(nèi)置工具類型中被使用最多的一個(gè):
type?Partial?=?{
??[K?in?keyof?T]?:?T[k];
};
它用于將一個(gè)接口中的字段全部變?yōu)榭蛇x,除了索引類型以及映射類型以外,它只使用了?可選修飾符,那么我現(xiàn)在直接掏出小抄:
去除可選修飾符: -?,位置與?一致只讀修飾符: readonly,位置在鍵名,如readonly key: string去除只讀修飾符: -readonly,位置同readonly。
恭喜,你得到了 Required 和 Readonly(去除 readonly 修飾符的工具類型不屬于內(nèi)置的,我們會(huì)在后面看到):
type?Required?=?{
??[K?in?keyof?T]-?:?T[K];
};
type?Readonly?=?{
??readonly?[K?in?keyof?T]:?T[K];
};
在上面我們實(shí)現(xiàn)了一個(gè) pick 函數(shù):
function?pick<T?extends?object,?U?extends?keyof?T>(obj:?T,?keys:?U[]):?T[U][]?{
??return?keys.map((key)?=>?obj[key]);
}
類似的,假設(shè)我們現(xiàn)在需要從一個(gè)接口中挑選一些字段:
type?Pickextends?keyof?T>?=?{
??[P?in?K]:?T[P];
};
//?期望用法
//?期望結(jié)果?A["a"]類型?|?A["b"]類型
type?Part?=?Pick"a"?|?"b">;
還是映射類型,只不過現(xiàn)在映射類型的映射源是傳入給 Pick 的類型參數(shù)K。
既然有了Pick,那么自然要有Omit(一個(gè)是從對(duì)象中挑選部分,一個(gè)是排除部分),它和Pick的寫法非常像,但有一個(gè)問題要解決:我們要怎么表示T中剔除了K后的剩余字段?
Pick 選取傳入的鍵值,Omit 移除傳入的鍵值
這里我們又要引入一個(gè)知識(shí)點(diǎn):never類型,它表示永遠(yuǎn)不會(huì)出現(xiàn)的類型,通常被用來將收窄聯(lián)合類型或是接口,或者作為條件類型判斷的兜底。詳細(xì)可以看《尤大的知乎回答》(https://www.zhihu.com/search?type=content&q=ts%20never), 在這里我們不做展開介紹。
上面的場(chǎng)景其實(shí)可以簡(jiǎn)化為:
//?"3"?|?"4"?|?"5"
type?LeftFields?=?Exclude<"1"?|?"2"?|?"3"?|?"4"?|?"5",?"1"?|?"2">;
Exclude,字面意思看起來是排除,那么第一個(gè)參數(shù)應(yīng)該是要進(jìn)行篩選的,第二個(gè)應(yīng)該是篩選條件!先按著這個(gè)思路試試:
這里實(shí)際上使用到了分布式條件類型的特性,假設(shè) Exclude 接收 T U 兩個(gè)類型參數(shù),T 聯(lián)合類型中的類型會(huì)依次與 U 類型進(jìn)行判斷,如果這個(gè)類型參數(shù)在 U 中,就剔除掉它(賦值為 never)。
接地氣的版本:
"1"在"1" | "2"里面嗎("1" extends "1"|"2" -> true)? 在的話,就剔除掉它(賦值為never),不在的話就保留。
type?Exclude?=?T?extends?U???never?:?T;
那么 Omit就很簡(jiǎn)單了,對(duì)原接口的成員,剔除掉傳入的聯(lián)合類型成員,應(yīng)用 Pick 即可。
type?Omitextends?keyof?any>?=?Pick>;
劇透下,幾乎所有使用條件類型的場(chǎng)景,把判斷后的賦值語句反一下,就會(huì)有新的場(chǎng)景,比如 Exclude 移除掉鍵名,那反一下就是保留鍵名:
type?Extract?=?T?extends?U???T?:?never;
再來看個(gè)常用的工具類型 Record,通常用于生成以聯(lián)合類型為鍵名(Keys),鍵值類型為Type的新接口,比如:
type?MyNav?=?"a"?|?"b"?|?"b";
interface?INavWidgets?{?
??widgets:?string[];??
??title?:?string;??
??keepAlive?:?boolean;
}const?router:?Record?=?{??
??a:?{?widget:?[""]?},??
??b:?{?widget:?[""]?},??
??c:?{?widget:?[""]?},
};
其實(shí)很簡(jiǎn)單,把 Keys 的每個(gè)鍵值拿出來,類型規(guī)定為 Type 即可
//?K?extends?keyof?any?約束K必須為聯(lián)合類型
type?Recordextends?keyof?any,?T>?=?{??
??[P?in?K]:?T;
};
注意,Record也支持 Record 這樣的使用方式, string extends keyof any 也是成立的,因?yàn)?keyof 的最終結(jié)果必然是 string 組成的聯(lián)合類型(除了使用數(shù)字作為鍵名的情況...)。
在前面的 infer 一節(jié)中我們實(shí)現(xiàn)了用于獲取函數(shù)返回值的ReturnType:
type?ReturnTypeextends?(...args:?any)?=>?any>?=?T?extends?(??...args:?any)?=>?infer?R????R??:?any;
其實(shí)把 infer 換個(gè)位置,比如放到入?yún)⑻帲妥兂闪双@取參數(shù)類型的Parameters:
type?Parametersextends?(...args:?any)?=>?any>?=?T?extends?(??...args:?infer?P)?=>?any????P??:?never;
如果再大膽一點(diǎn),把普通函數(shù)換成類的構(gòu)造函數(shù),那么就得到了獲取類構(gòu)造函數(shù)入?yún)㈩愋偷?code style="font-size: 14px;padding: 2px 4px;border-radius: 4px;margin-right: 2px;margin-left: 2px;color: rgb(30, 107, 184);background-color: rgba(27, 31, 35, 0.05);font-family: "Operator Mono", Consolas, Monaco, Menlo, monospace;word-break: break-all;">ConstructorParameters:
type?ConstructorParameters?T?extends?new?(...args:?any)?=>?any>?=?T?extends?new?(...args:?infer?P)?=>?any???P?:?never;
加上
new關(guān)鍵字來使其成為可實(shí)例化類型聲明,即此處約束泛型為類。
這個(gè)是獲得類的構(gòu)造函數(shù)入?yún)㈩愋停绻汛?infer 的類型放到其返回處,想想 new 一個(gè)類的返回值是什么?實(shí)例!所以我們得到了實(shí)例類型InstanceType:
type?InstanceTypeextends?new?(...args:?any)?=>?any>?=?T?extends?new?(
??...args:?any
)?=>?infer?R
????R
??:?any;
這幾個(gè)例子看下來,你應(yīng)該已經(jīng) get 到了那么一絲天機(jī),類型編程的確沒有特別高深晦澀的語法,它考驗(yàn)的是你對(duì)其中基礎(chǔ)部分如索引、映射、條件類型的掌握程度,以及舉一反三的能力。下面我們要學(xué)習(xí)的社區(qū)工具類型,本質(zhì)上還是各種基礎(chǔ)類型的組合,只是從常見場(chǎng)景下出發(fā),補(bǔ)充了官方?jīng)]有覆蓋到的部分。
社區(qū)工具類型
這一部分的工具類型大多來自于utility-types(https://github.com/piotrwitek/utility-types),其作者同時(shí)還有react-redux-typescript-guide(https://github.com/piotrwitek/react-redux-typescript-guide) 和typesafe-actions (https://github.com/piotrwitek/typesafe-actions)這兩個(gè)優(yōu)秀作品。
同時(shí),也推薦type-fest(https://github.com/sindresorhus/type-fest) 這個(gè)庫,和上面相比更加接地氣一些。其作者的作品...,我保證你直接或間接的使用過(如果不信,一定要去看看,我剛看到的時(shí)候是真的震驚的不行)。
我們由淺入深,先封裝基礎(chǔ)的類型別名和對(duì)應(yīng)的類型守衛(wèi):
export?type?Primitive?=
??|?string
??|?number
??|?bigint
??|?boolean
??|?symbol
??|?null
??|?undefined;
export?const?isPrimitive?=?(val:?unknown):?val?is?Primitive?=>?{
??if?(val?===?null?||?val?===?undefined)?{
????return?true;
??}
??const?typeDef?=?typeof?val;
??const?primitiveNonNullishTypes?=?[
????"string",
????"number",
????"bigint",
????"boolean",
????"symbol",
??];
??return?primitiveNonNullishTypes.indexOf(typeDef)?!==?-1;
};
export?type?Nullish?=?null?|?undefined;
export?type?NonUndefined?=?A?extends?undefined???never?:?A;
//?實(shí)際上TS也內(nèi)置了這個(gè)工具類型
type?NonNullable?=?T?extends?null?|?undefined???never?:?T;
Falsy和isFalsy我們已經(jīng)在上面體現(xiàn)過了。
趁著對(duì) infer 的記憶來熱乎,我們?cè)賮砜匆粋€(gè)常用的場(chǎng)景,提取 Promise 的實(shí)際類型:
const?foo?=?():?Promise<string>?=>?{
??return?new?Promise((resolve,?reject)?=>?{
????resolve("linbudu");
??});
};
//?Promise
type?FooReturnType?=?ReturnType<typeof?foo>;
//?string
type?NakedFooReturnType?=?PromiseType;
如果你已經(jīng)熟練掌握了infer的使用,那么實(shí)際上是很好寫的,只需要用一個(gè)infer參數(shù)作為 Promise 的泛型即可:
export?type?PromiseTypeextends?Promise<any>>?=?T?extends?Promise????U??:?never;
使用 infer R 來等待類型系統(tǒng)推導(dǎo)出R的具體類型。
遞歸的工具類型
前面我們寫了個(gè)Partial Readonly Required等幾個(gè)對(duì)接口字段進(jìn)行修飾的工具類型,但實(shí)際上都有局限性,如果接口中存在著嵌套呢?
type?Partial?=?{
??[P?in?keyof?T]?:?T[P];
};
理一下邏輯:
如果不是對(duì)象類型,就只是加上 ?修飾符如果是對(duì)象類型,那就遍歷這個(gè)對(duì)象內(nèi)部 重復(fù)上述流程。
是否是對(duì)象類型的判斷我們見過很多次了, T extends object即可,那么如何遍歷對(duì)象內(nèi)部?實(shí)際上就是遞歸。
export?type?DeepPartial?=?{
??[P?in?keyof?T]?:?T[P]?extends?object???DeepPartial?:?T[P];
};
utility-types內(nèi)部的實(shí)現(xiàn)實(shí)際比這個(gè)復(fù)雜,還考慮了數(shù)組的情況,這里為了便于理解做了簡(jiǎn)化,后面的工具類型也同樣存在此類簡(jiǎn)化。
那么DeepReadobly、 DeepRequired也就很簡(jiǎn)單了:
export?type?DeepMutable?=?{
??-readonly?[P?in?keyof?T]:?T[P]?extends?object???DeepMutable?:?T[P];
};
//?即DeepReadonly
export?type?DeepImmutable?=?{
??+readonly?[P?in?keyof?T]:?T[P]?extends?object???DeepImmutable?:?T[P];
};
export?type?DeepRequired?=?{
??[P?in?keyof?T]-?:?T[P]?extends?object?|?undefined???DeepRequired?:?T[P];
};
尤其注意下DeepRequired,它的條件類型判斷的是 T[P] extends object | undefined,因?yàn)榍短椎膶?duì)象類型可能是可選的(undefined),如果僅使用object,可能會(huì)導(dǎo)致錯(cuò)誤的結(jié)果。
另外一種省心的方式是不進(jìn)行條件類型的判斷,直接全量遞歸所有屬性~
返回鍵名的工具類型
在有些場(chǎng)景下我們需要一個(gè)工具類型,它返回接口字段鍵名組成的聯(lián)合類型,然后用這個(gè)聯(lián)合類型進(jìn)行進(jìn)一步操作(比如給 Pick 或者 Omit 這種使用),一般鍵名會(huì)符合特定條件,比如:
可選/必選/只讀/非只讀的字段 (非)對(duì)象/(非)函數(shù)/類型的字段
來看個(gè)最簡(jiǎn)單的函數(shù)類型字段FunctionTypeKeys:
export?type?FunctTypeKeysextends?object>?=?{
??[K?in?keyof?T]-?:?T[K]?extends?Function???K?:?never;
}[keyof?T];
{[K in keyof T]: ... }[keyof T]這個(gè)寫法可能有點(diǎn)詭異,拆開來看:
interface?IWithFuncKeys?{
??a:?string;
??b:?number;
??c:?boolean;
??d:?()?=>?void;
}
type?WTFIsThisextends?object>?=?{
??[K?in?keyof?T]-?:?T[K]?extends?Function???K?:?never;
};
type?UseIt1?=?WTFIsThis;
很容易推導(dǎo)出 UseIt1 實(shí)際上就是:
type?UseIt1?=?{??
??a:?never;??
??b:?never;??
??c:?never;??
??d:?"d";
};
UseIt會(huì)保留所有字段,滿足條件的字段其鍵值為字面量類型(即鍵名),不滿足的則為never。
加上后面一部分:
//?"d"
type?UseIt2?=?UseIt1[keyof?UseIt1];
這個(gè)過程類似排列組合:never類型的值不會(huì)出現(xiàn)在聯(lián)合類型中
//?never類型會(huì)被自動(dòng)去除掉?string?|?number
type?WithNever?=?string?|?never?|?number;
所以{ [K in keyof T]: ... }[keyof T]這個(gè)寫法實(shí)際上就是為了返回鍵名(準(zhǔn)備的說,是鍵名組成的聯(lián)合類型)。
那么非函數(shù)類型字段也很簡(jiǎn)單了,這里就不做展示了,下面來看可選字段OptionalKeys與必選字段RequiredKeys,先來看個(gè)小例子:
type?WTFAMI1?=?{}?extends?{?prop:?number?}???"Y"?:?"N";
type?WTFAMI2?=?{}?extends?{?prop?:?number?}???"Y"?:?"N";
如果能繞過來,很容易就能得出來答案。如果一時(shí)沒繞過去,也很簡(jiǎn)單,對(duì)于前面一個(gè)情況,prop是必須的,因此空對(duì)象 {} 并不能滿足extends { prop: number },而對(duì)于prop為可選的情況下則可以。
因此,我們使用這種思路來得到可選/必選的鍵名。
{} extends Pick,如果K是可選字段,那么就留下(OptionalKeys,如果是 RequiredKeys 就剔除)。怎么剔除?當(dāng)然是用 never了。
export?type?RequiredKeys?=?{
??[K?in?keyof?T]-?:?{}?extends?Pick???never?:?K;
}[keyof?T];
這里是剔除可選字段,那么 OptionalKeys 就是保留了:
export?type?OptionalKeys?=?{
??[K?in?keyof?T]-?:?{}?extends?Pick???K?:?never;
}[keyof?T];
只讀字段IMmutableKeys與非只讀字段MutableKeys的思路類似,即先獲得:
interface?MutableKeys?{
??readonlyKeys:?never;
??notReadonlyKeys:?"notReadonlyKeys";
}
然后再獲得不為never的字段名即可。
這里還是要表達(dá)一下對(duì)作者的敬佩,屬實(shí)巧妙啊,首先定義一個(gè)工具類型IfEqual,比較兩個(gè)類型是否相同,甚至可以比較修飾前后的情況下,也就是這里只讀與非只讀的情況。
type?Equal?=?(()?=>?T?extends?X???1?:?2 )?extends?<
??T
>()?=>?T?extends?Y???1?:?2
????A
??:?B;
不要被 干擾,可以理解為就是用于比較的包裝,這一層包裝能夠區(qū)分出來只讀與非只讀屬性。即() => T extends X ? 1 : 2 (這一部分,只有在類型參數(shù)() => T extends X ? 1 : 2) X完全一致時(shí),兩個(gè)(` 才會(huì)是全等的,這個(gè)一致要求只讀性、可選性等修飾也要一致。() => T extends X ? 1 : 2) 實(shí)際使用時(shí)(以非只讀的情況為例),我們?yōu)?X 傳入接口,為 Y 傳入去除了只讀屬性 -readonly的接口,使得所有鍵都被進(jìn)行一次與去除只讀屬性的鍵的比較。為 A 傳入字段名,B 這里我們需要的就是 never,因此可以不填。
實(shí)例:
export?type?MutableKeysextends?object>?=?{
??[P?in?keyof?T]-?:?Equal<
????{?[Q?in?P]:?T[P]?},
????{?-readonly?[Q?in?P]:?T[P]?},
????P,
????never
??>;
}[keyof?T];
幾個(gè)容易繞彎子的點(diǎn):
泛型 Q 在這里不會(huì)實(shí)際使用,只是映射類型的字段占位。 X 、 Y 同樣存在著 分布式條件類型, 來依次比對(duì)字段去除 readonly 前后。
同樣的有:
export?type?IMmutableKeysextends?object>?=?{
??[P?in?keyof?T]-?:?Equal<
????{?[Q?in?P]:?T[P]?},
????{?-readonly?[Q?in?P]:?T[P]?},
????never,
????P
??>;
}[keyof?T];
這里不是對(duì) readonly修飾符操作,而是調(diào)換條件類型的判斷語句。
基于值類型的 Pick 與 Omit
前面我們實(shí)現(xiàn)的 Pick 與 Omit 是基于鍵名的,假設(shè)現(xiàn)在我們需要按照值類型來做選取剔除呢?
其實(shí)很簡(jiǎn)單,就是T[K] extends ValueType即可:
export?type?PickByValueType?=?Pick<
??T,
??{?[Key?in?keyof?T]-?:?T[Key]?extends?ValueType???Key?:?never?}[keyof?T]
>;
export?type?OmitByValueType?=?Pick<
??T,
??{?[Key?in?keyof?T]-?:?T[Key]?extends?ValueType???never?:?Key?}[keyof?T]
>;
條件類型承擔(dān)了太多...
工具類型一覽
總結(jié)下我們上面書寫的工具類型:
全量修飾接口: PartialReadonly(Immutable)MutableRequired,以及對(duì)應(yīng)的遞歸版本。裁剪接口: PickOmitPickByValueTypeOmitByValueType基于 infer: ReturnTypeParamTypePromiseType獲取指定條件字段: FunctionKeysOptionalKeysRequiredKeys...
需要注意的是,有時(shí)候單個(gè)工具類型并不能滿足你的要求,你可能需要多個(gè)工具類型協(xié)作,比如用 FunctionKeys + Pick 得到一個(gè)接口中類型為函數(shù)的字段。
另外,實(shí)際上上面的部分工具類型是可以用重映射能力實(shí)現(xiàn)的更加簡(jiǎn)潔優(yōu)雅的,這不嘗試下?
受限于篇幅(本文到這里已經(jīng)1.3w字了),本來還想放上來的 type-fest 的工具類型就只能遺憾退場(chǎng)了,但我還是建議大家去讀一讀它的源碼。相比于上面的 utility-types 更加接地氣,實(shí)現(xiàn)思路也更加有趣。
TypeScript 4.x 中的部分新特性
這一部分是相對(duì)于之前的版本新增的部分,主要包括了4.1 - 4.4(Beta)版本中引入的一部分與本文介紹內(nèi)容有關(guān)的新特性,包括 模板字面量類型 與 重映射。
模板字面量類型
TypeScript 4.1 中引入了模板字面量類型,使得我們可以使用${} 這一語法來構(gòu)造字面量類型,如:
type?World?=?'world';
//?"hello?world"
type?Greeting?=?`hello?${World}`;
模板字面量類型同樣支持分布式條件類型,如:
export?type?SizeRecordextends?string>?=?`${Size}-Record`
//?"Small-Record"
type?SmallSizeRecord?=?SizeRecord<"Small">
//?"Middle-Record"
type?MiddleSizeRecord?=?SizeRecord<"Middle">
//?"Huge-Record"
type?HugeSizeRecord?=?SizeRecord<"Huge">
//?"Small-Record"?|?"Middle-Record"?|?"Huge-Record"
type?UnionSizeRecord?=?SizeRecord<"Small"?|?"Middle"?|?"Huge">
還有個(gè)有趣的地方,模板插槽(${})中可以傳入聯(lián)合類型,并且同一模板中如果存在多個(gè)插槽,各個(gè)聯(lián)合類型將會(huì)被分別排列組合。
//?"Small-Record"?|?"Small-Report"?|?"Middle-Record"?|?"Middle-Report"?|?"Huge-Record"?|?"Huge-Report"
type?SizeRecordOrReport?=?`${"Small"?|?"Middle"?|?"Huge"}-${"Record"?|?"Report"}`;
隨之而來的還有四個(gè)新的工具類型:
type?Uppercaseextends?string>?=?intrinsic;
type?Lowercaseextends?string>?=?intrinsic;
type?Capitalizeextends?string>?=?intrinsic;
type?Uncapitalizeextends?string>?=?intrinsic;
它們的作用就是字面意思,不做解釋了。相關(guān)的PR見 40336,作者Anders Hejlsberg 是 C# 與 Delphi 的首席架構(gòu)師,同時(shí)也是TS的作者之一。
intrinsic代表了這些工具類型是由 TS 編譯器內(nèi)部實(shí)現(xiàn)的,其實(shí)也很好理解,我們無法通過類型編程來改變字面量的值,但我想按照這個(gè)趨勢(shì),TS類型編程以后會(huì)支持調(diào)用 Lodash 方法也說不定。
TS 的實(shí)現(xiàn)代碼:
function?applyStringMapping(symbol:?Symbol,?str:?string)?{
??switch?(intrinsicTypeKinds.get(symbol.escapedName?as?string))?{
????case?IntrinsicTypeKind.Uppercase:?return?str.toUpperCase();
????case?IntrinsicTypeKind.Lowercase:?return?str.toLowerCase();
????case?IntrinsicTypeKind.Capitalize:?return?str.charAt(0).toUpperCase()?+?str.slice(1);
????case?IntrinsicTypeKind.Uncapitalize:?return?str.charAt(0).toLowerCase()?+?str.slice(1);
??}
??return?str;
}
你可能會(huì)想到,模板字面量如果想截取其中的一部分要怎么辦?這里可沒法調(diào)用 slice 方法。其實(shí)思路就在我們上面提到過的 infer,使用 infer 占位后,便能夠提取出字面量的一部分,如:
type?CutStrextends?string>?=?Str?extends?`${infer?Part}budu`???Part?:?never
//?"lin"
type?Tmp?=?CutStr<"linbudu">
再進(jìn)一步,[1,2,3]這樣的字符串,如果我們提供 [${infer Member1}, ${infer Member2}, ${infer Member}] 這樣的插槽匹配,就可以實(shí)現(xiàn)神奇的提取字符串?dāng)?shù)組成員效果:
type?ExtractMemberextends?string>?=?Str?extends?`[${infer?Member1},?${infer?Member2},?${infer?Member3}]`???[Member1,?Member2,?Member3]?:?unknown;
//?["1",?"2",?"3"]
type?Tmp?=?ExtractMember<"[1,?2,?3]">
注意,這里的模板插槽被使用 , 分隔開了,如果多個(gè)帶有 infer 的插槽緊挨在一起,那么前面的 infer 只會(huì)獲得單個(gè)字符,最后一個(gè) infer 會(huì)獲得所有的剩余字符(如果有的話),比如我們把上面的例子改成這樣:
type?ExtractMemberextends?string>?=?Str?extends?`[${infer?Member1}${infer?Member2}${infer?Member3}]`???[Member1,?Member2,?Member3]?:?unknown;
//?["1",?",",?"?2,?3"]
type?Tmp?=?ExtractMember<"[1,?2,?3]">
這一特性使得我們可以使用多個(gè)相鄰的 infer + 插槽,對(duì)最后一個(gè) infer獲得的值進(jìn)行遞歸操作,如:
type?JoinArrayMemberextends?unknown[],?D?extends?string>?=??
??T?extends?[]???''?:??
??T?extends?[any]???`${T[0]}`?:??
??T?extends?[any,?...infer?U]???`${T[0]}${D}${JoinArrayMember}`?:??
??string;
??
//?""
type?Tmp1?=?JoinArrayMember<[],?'.'>;
//?"1"
type?Tmp3?=?JoinArrayMember<[1],?'.'>;
//?"1.2.3.4"
type?Tmp2?=?JoinArrayMember<[1,?2,?3,?4],?'.'>;
原理也很簡(jiǎn)單,每次將數(shù)組的第一個(gè)成員添加上.,在最后一個(gè)成員時(shí)不作操作,在最后一次匹配([])返回空字符串,即可。
又或者反過來?把 1.2.3.4 回歸到數(shù)組形式?
type?SplitArrayMemberextends?string,?D?extends?string>?=
??string?extends?S???string[]?:
??S?extends?''???[]?:
??S?extends?`${infer?T}${D}${infer?U}`???[T,?...SplitArrayMember]?:
??[S];
type?Tmp11?=?SplitArrayMember<'foo',?'.'>;??//?['foo']
type?Tmp12?=?SplitArrayMember<'foo.bar.baz',?'.'>;??//?['foo',?'bar',?'baz']
type?Tmp13?=?SplitArrayMember<'foo.bar',?''>;??//?['f',?'o',?'o',?'.',?'b',?'a',?'r']
type?Tmp14?=?SplitArrayMember<any,?'.'>;??//?stri?
最后,看到 a.b.c 這樣的形式,你應(yīng)該想到了 Lodash 的 get 方法,即通過 get({},"a.b.c") 的形式快速獲得嵌套屬性。但是這樣要怎么提供類型聲明?有了模板字面量類型后,只需要結(jié)合 infer + 條件類型即可。
type?PropTypeextends?string>?=
????string?extends?Path???unknown?:
????Path?extends?keyof?T???T[Path]?:
????Path?extends?`${infer?K}.${infer?R}`???K?extends?keyof?T???PropType?:?unknown?:
????unknown;
declare?function?getPropValue<T,?P?extends?string>(obj:?T,?path:?P):?PropType<T,?P>;
declare?const?s:?string;
const?obj?=?{?a:?{?b:?{c:?42,?d:?'hello'?}}};
getPropValue(obj,?'a');??//?{?b:?{c:?number,?d:?string?}?}
getPropValue(obj,?'a.b');??//?{c:?number,?d:?string?}
getPropValue(obj,?'a.b.d');??//?string
getPropValue(obj,?'a.b.x');??//?unknown
getPropValue(obj,?s);??//?unknown
重映射
這一能力在 TS 4.1(https://devblogs.microsoft.com/typescript/announcing-typescript-4-1/#key-remapping-mapped-types) 中引入,提供了在映射類型中重定向映射源至新類型的能力,這里的新類型可以是工具類型的返回結(jié)果、字面量模板類型等,用于解決在使用映射類型時(shí),我們想要過濾/新增拷貝的接口成員,通常會(huì)將原接口成員的鍵作為新的轉(zhuǎn)換方法參數(shù),如:
type?Getters?=?{
????[K?in?keyof?T?as?`get${Capitalize<string?&?K>}`]:?()?=>?T[K]
};
interface?Person?{
????name:?string;
????age:?number;
????location:?string;
}
type?LazyPerson?=?Getters;
轉(zhuǎn)換后的結(jié)果:
type?LazyPerson?=?{
????getName:?()?=>?string;
????getAge:?()?=>?number;
????getLocation:?()?=>?string;
}
這里的
string & k是因?yàn)橹赜成涞霓D(zhuǎn)換方法(即as后面的部分)必須是可分配給string | number | symbol的,而 K 來自于keyof,可能包含symbol類型,這樣的話是不能交給模板字面量類型使用的。
如果轉(zhuǎn)換方法返回了never,那么這個(gè)成員就被除去了,所以我們可以使用這個(gè)方法來過濾掉成員。
type?RemoveKindField?=?{
????[K?in?keyof?T?as?Exclude"kind">]:?T[K]
};
interface?Circle?{
????kind:?"circle";
????radius:?number;
}
//?type?KindlessCircle?=?{
//?????radius:?number;
//?}
type?KindlessCircle?=?RemoveKindField;
最后,當(dāng)與模板字面量一同使用時(shí),由于其排列組合的特性,如果重映射的轉(zhuǎn)換方法是一個(gè)由 模板字面量類型 組成的 聯(lián)合類型,那么就會(huì)從排列組合得到多個(gè)成員。
type?DoubleProp?=?{?[P?in?keyof?T?&?string?as?`${P}1`?|?`${P}2`]:?T[P]?}
type?Tmp?=?DoubleProp<{?a:?string,?b:?number?}>;??//?{?a1:?string,?a2:?string,?b1:?number,?b2:?number?}
尾聲
這篇文章確實(shí)很長(zhǎng)很長(zhǎng),因不建議一次性囫圇吞棗的讀完,建議選取幾段有一定長(zhǎng)度的連續(xù)時(shí)間,給它掰開了揉碎了好好讀懂。寫文不易,尤其是寫這么長(zhǎng)的文章,但是如果能幫助你的 TypeScript 更上一層樓,就完全值得了。
如果在之前,你從未關(guān)注過類型編程方面,那么閱讀完畢后可能需要一定時(shí)間來適應(yīng)思路的轉(zhuǎn)變。還是那句話,認(rèn)識(shí)到 類型編程的本質(zhì)也是編程。當(dāng)然,你也可以漸進(jìn)式的開始實(shí)踐這一點(diǎn),比如從今天開始,從現(xiàn)在手頭里的項(xiàng)目開始,從泛型到類型守衛(wèi),從索引/映射類型到條件類型,從使用工具類型到封裝工具類型,一步步變成 TypeScript 高高手。
