【TypeScript】never 和 unknown 的優(yōu)雅之道
關(guān)注并將「趣談前端」設(shè)為星標(biāo)
定期推送技術(shù)干貨/優(yōu)秀開源/技術(shù)思維
1、前言
?TypeScript 在版本 2.0 和 3.0 分別引入了 “never” 和 “unknown” 兩個基本類型,在引入這兩個類型之后,TypeScript 的類型系統(tǒng)得到了極大的完善。
但在我平時接手代碼的時候,我發(fā)現(xiàn)很多同學(xué)的觀念還停留在 1.0 的時代,那個 any 大法好的時代。畢竟 JavaScript 是一門弱類型動態(tài)語言,我們以往不會投入過多的時間去關(guān)注類型設(shè)計。在引入 TypeScript 之后,我們甚至還會抱怨:“這代碼怎么還越寫越多了?”。
其實我們應(yīng)該反過來思考,OOP 的編程范式,才是 ES6 后的代碼應(yīng)該有的模樣。
2、TypeScript 中的 top type、bottom type
在類型系統(tǒng)設(shè)計中,有兩種特別的類型:
Top type:被稱為通用父類型,也就是能夠包含所有值的類型。
Bottom type:代表沒有值的類型,它也被稱為零或空類型,是所有類型的子類型。
按照類型系統(tǒng)的解釋,在 TypeScript 3.0 中,有兩個 top type(any 和 unknown) 和一個 bottom type(never)。

但也有一些人認(rèn)為,any 也是一個 bottom type,因為 any 也可以作為很多類型的子類型。但這種說法其實并不嚴(yán)格,我們可以深入了解一下 unknown、any、never 這三個類型。
3、unknown 和 any
3.1 unknown —— 代表萬物
我在閱讀同事的代碼時,很少看到 unknown 類型的出現(xiàn)。這并不意味著它不重要,相反,它是安全版本的 any 類型。
它和 any 的區(qū)別很簡單,參考下面的例子:
function format1(value: any) {
value.toFixed(2); // 不飄紅,想干什么干什么,very dangerous
}
function format2(value: unknown) {
value.toFixed(2); // 代碼會飄紅,阻止你這么做
// 你需要收窄類型范圍,例如:
// 1、類型斷言 —— 不飄紅,但執(zhí)行時可能錯誤
(value as Number).toFixed(2);
// 2、類型守衛(wèi) —— 不飄紅,且確保正常執(zhí)行
if (typeof value === 'number') {
// 推斷出類型: number
value.toFixed(2);
}
// 3、類型斷言函數(shù),拋出錯誤 —— 不飄紅,且確保正常執(zhí)行
assertIsNumber(value);
value.toFixed(2);
}
/** 類型斷言函數(shù),拋出錯誤 */
function assertIsNumber(arg: unknown): asserts arg is Number {
if (!(arg instanceof Number)) {
thrownewTypeError('Not a Number: ' + arg);
}
}
使用 any 好比鬼屋探險,代碼執(zhí)行的時候處處見鬼。而 unknown 結(jié)合類型守衛(wèi)等方式,可以確保上游數(shù)據(jù)結(jié)構(gòu)不確定時,也能讓代碼正常執(zhí)行。
3.2 any —— 一絲不掛
我們用到 any,就意味著放棄類型檢查了,因為它不是用來描述具體類型的。
在使用它之前,我們需要想兩件事:
能否使用更具體的類型
能否使用 unknown 代替
都不能的情況下,any 才是最后的選擇。
3.3 回顧以前的類型設(shè)計
現(xiàn)有的一些類型設(shè)計用到了 any,其實不夠準(zhǔn)確。這里舉兩個例子:
3.3.1 String()
String()?能夠接受任何參數(shù),轉(zhuǎn)化為字符串。
結(jié)合上文介紹的 unknown 類型,其實這里的參數(shù)也可以設(shè)計成 unknown,但內(nèi)部實現(xiàn)就需要多設(shè)計些類型守衛(wèi)了。但 unknown 類型是后面才出現(xiàn)的,所以一開始的設(shè)計還是采用了 any,也就是我們現(xiàn)在看到的:
/**
* typescript/lib/lib.es5.d.ts
*/
interface StringConstructor {
new(value?: any): String;
(value?: any): string;
readonly prototype: String;
fromCharCode(...codes: number[]): string;
}
3.3.2 JSON.parse()
最近我寫了一段涉及深拷貝的代碼:
exportfunction deleteCommentFromComments<T>(comments: GenericsComment[], comment: GenericsComment ) {
// 深拷貝
const list: GenericsComment[] = JSON.parse(JSON.stringify(comments));
// 找到對應(yīng)的評論下標(biāo)
const targetIndex = list.findIndex((item) => {
if (item.comment_id === comment.comment_id) {
returntrue;
}
returnfalse;
});
if (targetIndex !== -1) {
// 剔除對應(yīng)的評論
list.splice(targetIndex, 1);
}
return list;
}
很明顯,JSON.parse () 的輸出是隨著輸入動態(tài)改變的(甚至有可能拋出 Error),它的函數(shù)簽名被設(shè)計成了:
interface JSON {
parse(text: string, reviver?: (this: any, key: string, value: any) =>any): any;
...
}
這里可以用 unknown 嘛?可以,不過原因和上面一樣,JSON.parse()?的函數(shù)簽名被添加到 TypeScript 系統(tǒng)之前,unknown 類型還沒出現(xiàn),否則它的返回類型應(yīng)該是 unknown。
4、never
上文提到,never?類型表示的是空類型,也就是值永不存在的類型。
值會永不存在的兩種情況:
如果一個函數(shù)執(zhí)行時拋出了異常,那么這個函數(shù)永遠(yuǎn)不存在返回值(因為拋出異常會直接中斷程序運行,這使得程序運行不到返回值那一步,即具有不可達的終點,也就永不存在返回了);
函數(shù)中執(zhí)行無限循環(huán)的代碼(死循環(huán)),使得程序永遠(yuǎn)無法運行到函數(shù)返回值那一步,永不存在返回。
// 異常
function err(msg: string): never { // OK
throw new Error(msg);
}
// 死循環(huán)
function loopForever(): never { // OK
while (true) {};
}
4.1 唯一的 bottom type
由于 never 是 typescript 的唯一一個 bottom type,它能夠表示任何類型的子類型,所以能夠賦值給任何類型:
let err: never;
let num: number = 4;
num = err; // OK
我們可以使用集合來理解 never,unknown 是全集,never 是最小單元(空集),任意類型都包含了 never。
4.1.1 null/undefined 和 never
這里可能就要問了,null 和 undefined 好像也可以表示任何類型的子類型,為啥不是 bottom type。非也,never 特殊就特殊在,除了自身以外,沒有任何類型是它的子類型,或者說可以賦值給它。它才是人下人(狗頭),我們可以用下面的例子對比看看:
// null 和 undefined,可以被 never 賦值
declare const n: never;
let a: null = n; // 正確
let b: undefined = n; // 正確
// never 是 bottom type,除了自己以外沒有任何類型可以賦值給它
let ne: never;
ne = null; // 錯誤
ne = undefined; // 錯誤
declare const an: any;
ne = an; // 錯誤,any 也不可以
declareconst nev: never;
ne = nev; // 正確,只有 never 可以賦值給 never上面的例子基本上說明了 null/undefined 跟 never 的區(qū)別,never 才是最 bottom 的。
4.1.2 為什么說 any 不是嚴(yán)格的 bottom type
我在閱讀一些文章的時候發(fā)現(xiàn),大家常說 any 既是 top type,也是 bottom type,但這種說法并不嚴(yán)謹(jǐn)。
從上文我們知道,除了 never 自身,沒有任何類型能賦值給 never。any 是否滿足這個特性呢?顯然不能,舉個很簡單的例子:
const a = 'anything';
const b: any = a; // 能夠賦值
const c: never = a; // 報錯,不能賦值而我們?yōu)槭裁凑f never 才是 bottom type?維基百科上這樣解釋:
A function whose return type is bottom (presumably) cannot return any value, not even the zero size unit type. Therefore a function whose return type is the bottom type cannot return.
返回類型為底部類型的函數(shù)不能返回任何值,甚至不能返回零大小的單元類型。因此返回類型為底部類型的函數(shù)不能返回。
從這里我們也很容易發(fā)現(xiàn),在一個類型系統(tǒng)中,bottom type 是獨一無二的,它唯一地描述了函數(shù)無返回的情況。所以,有了 never 之后,any 這種脫離了類型檢查的異端肯定稱不上是 bottom type。
4.2 never 的妙用
never 有以下的使用場景:
Unreachable code 檢查:標(biāo)記不可達代碼,獲得編譯提示。
類型運算:作為類型運算中的最小因子。
Exhaustive Check:為復(fù)合類型創(chuàng)造編譯提示。
......
關(guān)于 never 的用途,知乎上有個很好的討論。不可否認(rèn)的是,never 這個東西很奇妙,從集合論的角度,它是一個空集合,因此它可以通過空集合的一些特性,為我們的類型運算工作帶來很大便利。接下來來具體講講各個使用場景:
4.2.1 Unreachable code 檢查
一個萌新寫出了下面這行代碼:
process.exit(0);
console.log("hello world") // Unreachable code detected.ts(7027)不要笑,是真的有可能。當(dāng)然這時候如果你使用了 ts,它會給你一個編譯器提示:
Error: Unreachable code detected.ts(7027)
因為?process.exit()?返回類型被定義為了 never,在它之后的自然就是「unreachable code」了。
其他可能的場景還有,監(jiān)聽套接字:
function listen(): never {
while(true){
let conn = server.accept();
}
}
listen();
console.log("!!!"); // Unreachable code detected.ts(7027)通常來說,我們手動標(biāo)記函數(shù)返回值為 never 類型,來幫助編譯器識別「unreachable code」,并幫助我們收窄(narrow)類型。下面是一個沒標(biāo)記的例子:
function throwError() {
throw new Error();
}
function firstChar(msg: string | undefined) {
if (msg === undefined)
throwError();
let chr = msg.charAt(1) // Object is possibly 'undefined'.
}
由于編譯器不知道 throwError 是一個無返回的函數(shù),所以?throwError()?之后的代碼被認(rèn)為在任意情況下都是可達的,讓編譯器誤會 msg 的類型是 string | undefined。
這時候如果標(biāo)記上了 never 類型,那么 msg 的類型將會在空檢查之后收窄為 string:
function throwError(): never {
throw new Error();
}
function firstChar(msg: string | undefined) {
if (msg === undefined)
throwError();
let chr = msg.charAt(1) // ?
}
4.2.2 類型運算
4.2.2.1 最小因子
上文提到 never 可以理解為一個空集,那么它將滿足下面的運算規(guī)則:
T | never => T
T & never =>never也就是說,never 是類型運算的最小因子。這些規(guī)則幫助我們簡化了一些瑣碎的類型運算,舉個例子,像?Promise.race?合并的多個?Promise,有時是無法確切知道時序和返回結(jié)果的。現(xiàn)在我們使用一個?Promise.race?來將一個有網(wǎng)絡(luò)請求返回值的?Promise?和另一個在給定時間之內(nèi)就會被?reject?的?Promise?合并起來。
asyncfunction fetchNameWithTimeout(userId: string): Promise<string> {
const data = await Promise.race([
fetchData(userId),
timeout(3000)
])
return data.userName;
}
下面是一個 timeout 函數(shù)的實現(xiàn),如果超過指定時間,將會拋出一個 Error。由于它是無返回的,所以返回結(jié)果定義為了?Promise:
function timeout(ms: number): Promise<never> {
return new Promise((_, reject) => {
setTimeout(() => reject(newError("Timeout!")), ms)
})
}
很好,接下來編譯器會去推斷?Promise.race?的返回值,因為 race 會取最先完成的那個?Promise?的結(jié)果,所以在上面這個例子里,它的函數(shù)簽名類似這樣:
function race<A, B>(inputs: [Promise, Promise]): Promise<A | B>代入 fetchData 和 timeout 進來,A 則是?{ userName: string },而 B 則是?never。因此,函數(shù)輸出的?promise?返回值類型為?{ userName: string } | never。?又因為?never?是最小因子,可以消去。故返回值可簡化為?{ userName: string },這正是我們希望的。
那如果在這里使用了?any?或者?unknown,結(jié)果又會怎樣呢?
// 使用 any
function timeout(ms: number): Promise<any> {
......
}
// { userName: string } | any => any,失去了類型檢查
asyncfunction fetchNameWithTimeout(userId: string): Promise<string> {
......
return data.userName; // ? data 被推斷為 any
}
any 很好理解,雖然能正常通過,但相當(dāng)于沒有類型檢查了。
// 使用 unknown
function timeout(ms: number): Promise<unknown> {
......
}
// { userName: string } | unknown => unknown,類型被模糊
asyncfunction fetchNameWithTimeout(userId: string): Promise<string> {
......
return data.userName; // ? data 被推斷為 unknown
}
unknown 則是模糊了類型,需要我們手動去收窄類型。
當(dāng)我們嚴(yán)格使用 never 來描述「unreachable code」時,編譯器便能夠幫助我們準(zhǔn)確地收窄類型,做到代碼即文檔。
4.2.2.2 條件類型中使用
我們經(jīng)常在條件類型中見到 never,它被用于表示 else 的情況。
type Arguments = T extends (...args: infer A) => any ? A : never
type Return = T extends (...args: any[]) => infer R ? R : never 對于上述推導(dǎo)函數(shù)參數(shù)和返回值的兩個條件類型,即使傳入的 T 是非函數(shù)類型,我們也能夠得到編譯器的提示:
// Error: Type '3' is not assignable to type 'never'
const x: Return<"not a function type"> = 3;
在收窄聯(lián)合類型時,never 也巧妙地發(fā)揮了它作為最小因子的作用。比如說下面這個從?T?中排除?null?和?undefined?的例子:
type NullOrUndefined = null | undefined
type NonNullable = T extends NullOrUndefined ? never : T
// 運算過程
type NonNullable<string | null>
// 聯(lián)合類型被分解成多個分支單獨運算
=> (string extends NullOrUndefined ? never : string) | (nullextends NullOrUndefined ? never : null)
// 多個分支得到結(jié)果,再次聯(lián)合
=> string | never
// never 在聯(lián)合類型運算中被消解
=> string 4.2.3 Exhaustive Check
聯(lián)合類型、代數(shù)數(shù)據(jù)類型等復(fù)合類型,可以結(jié)合 switch 語句來進行類型收窄:
interface Foo {
type: 'foo'
}
interface Bar {
type: 'bar'
}
type All = Foo | Bar;
function handleValue(val: All) {
switch (val.type) {
case'foo':
// val 此時是 Foo
break;
case'bar':
// val 此時是 Bar
break;
default:
// val 此時是 never
const exhaustiveCheck: never = val;
break;
}
}
如果后面有人修改了?All?類型,它會發(fā)現(xiàn)產(chǎn)生了一個編譯錯誤:
type All = Foo | Bar | Baz;
function handleValue(val: All) {
switch (val.type) {
case'foo':
// val 此時是 Foo
break;
case'bar':
// val 此時是 Bar
break;
default:
// val 此時是 Baz
// ? Type 'Baz' is not assignable to type 'never'.(2322)
const exhaustiveCheck: never = val;
break;
}
}
在 default branch 里面 val 會被收窄為?Baz,導(dǎo)致無法賦值給 never,產(chǎn)生一個編譯錯誤。開發(fā)者能夠意識到 handleValue 里面需要加上針對 Baz 的處理邏輯。通過這個辦法,可以確保 handleValue 總是窮盡 (exhaust) 了 All 所有可能的類型。
5、結(jié)語
對重視類型規(guī)范和代碼設(shè)計的同學(xué)來說,TypeScript 絕不是枷鎖,而是一門實用主義語言。通過深入了解 never 和 unknown 在 TypeScript 類型系統(tǒng)中的使用和地位,可以學(xué)習(xí)到不少類型系統(tǒng)設(shè)計和集合論的知識,在實際開發(fā)中合理 narrow 類型,組織起可靠安全的代碼。
?? 看完三件事
如果你覺得這篇內(nèi)容對你挺有啟發(fā),我想邀請你幫我三個小忙:
點個【在看】,或者分享轉(zhuǎn)發(fā),讓更多的人也能看到這篇內(nèi)容 關(guān)注公眾號【趣談前端】,定期分享?工程化?/?可視化?/?低代碼?/?優(yōu)秀開源。

點個在看你最好看

