TypeScript 4.8 beta 發佈:正在路上的裝飾器、類型收窄增強、模板字符串類型中的 infer

語言: CN / TW / HK

TypeScript 已於 2022.06.21 發佈 4.8 beta 版本,你可以在 4.8 Iteration Plan 查看所有被包含的 Issue 與 PR。如果想要搶先體驗新特性,執行:

bash $ npm install typescript@beta

來安裝 beta 版本的 TypeScript,或在 VS Code 中安裝 JavaScript and TypeScript Nightly 來更新內置的 TypeScript 支持。

本篇是筆者的第四篇 TypeScript 更新日誌,上一篇是 「TypeScript 4.7 beta 發佈:NodeJs 的 ES Module 支持、新的類型編程語法、類型控制流分析增強等」,你可以在此賬號的創作中找到,接下來筆者也將持續更新 TypeScript 的 DevBlog 相關,感謝你的閲讀。

另外,由於 beta 版本與正式版本通常不會有明顯的差異,這一系列只會介紹 beta 版本而非正式版本。

關於 4.8 正式版本的詳細分析,以及新版裝飾器的使用,我會在 TypeScript 全面進階指南 中更新。

正在路上的裝飾器

在 4 月份的 TC39 雙月會議上,裝飾器提案成功進入到 Stage 3,這也是裝飾器提案歷經數個版本以後離 Stage 4 最近的一次。TypeScript 中同樣大量使用了裝飾器相關的語法,但實際上 TS 中的裝飾器(experimental)、Babel 中的裝飾器(legacy)都是基於第一版的裝飾器提案進行實現的,而目前 TC39 中的裝飾器提案已經迭代到了第三版。

如果你有興趣瞭解更多裝飾器的歷史,可以閲讀筆者的 走近MidwayJS:初識TS裝飾器與IoC機制 中的介紹,或者賀師俊老師在 是否應該在production裏使用typescript的decorator? 的回答。

隨着新版裝飾器提案的更新,TypeScript 勢必也需要對應地進行支持,但由於其工作量較大,目前 4.8 beta 版本中並不包含新版裝飾器的相關介紹(所以説正在路上),但其具體功能必然與裝飾器提案 proposal-decorators 中的介紹基本是一致的。

雖然我們迎來了新版裝飾器,但也無需擔心舊版裝飾器從此就被掃進歷史的塵埃裏了,對舊版裝飾器的支持肯定是會被保留相當長一段時間的,語言支持、框架改進、用户接受,每一步都快不起來。我們可能要到 TypeScript 20.0 beta 版本中才會看到官方宣佈將廢棄對實驗性裝飾器的支持,希望那時筆者仍然在更新此專欄。

對於使用者來説,基本不用擔心額外的學習成本,新版裝飾器在大部分情況下能完全覆蓋掉舊版裝飾器的能力。但對於框架基礎庫開發者來説,兩個版本裝飾器之間的差異確實相當大,如裝飾器的運行順序以及元數據相關等。

如果你有興趣瞭解新版裝飾器的詳細使用,可以閲讀筆者此前發表的 ECMAScript 雙月報告:裝飾器提案進入 Stage 3 ,瞭解新版裝飾器的功能、舊版裝飾器的廢棄原因,以及新版裝飾器如何不通過反射元數據的方式實現依賴注入。

交叉類型與聯合類型的類型收窄增強

TypeScript 4.8 版本對 --strictNullChecks 進行了進一步增強,主要體現在聯合類型與交叉類型,以及類型收窄地表現上。

舉例來説,作為 TypeScript 類型系統中的 Top Type ,unknown 類型包含所有的其他類型,實際上 unknown 和 {} | null | undefined 的效果是一致的:獨特意義的 null、undefined 類型,加上萬物起源的 {}

為什麼説 {} 是萬物起源?基於 TypeScript 的結構化類型比較,兩個類型之間的兼容性是通過它們內部的屬性類型是否一致來比較的:

```typescript class Cat {  eat() { } }

class Dog {  eat() { } }

function feedCat(cat: Cat) { }

feedCat(new Dog()) ```

在這個例子中 feedCat 函數可以接受 Dog 類型的參數,原因就是 Dog 類型與 Cat 類型在結構化類型系統比較下被認為是一致的。

更進一步,如果此時 Dog 新增一個方法:

```typescript class Cat {  eat() { } }

class Dog {  eat() { }    bark() { } }

function feedCat(cat: Cat) { }

feedCat(new Dog()) ```

此時這個例子仍然成立,原因就在於此時 Dog 類型相比 Cat 類型多了一個屬性,在結構化類型系統的判斷下可以認為 Dog 類型是 Cat 類型的子類型,就像這樣:

typescript class Dog extends Cat {    bark() { } }

回到正題,由於 {} 就是一個空對象,因此除 null、undefined 以外的一切基礎類型,都可以被視為是繼承於 {} 之後派生出來的

在 4.8 版本,現在 unknown{} | null | undefined 可以互相兼容:

```typescript declare let v1: unknown; declare let v2: {} | null | undefined;

v1 = v2; // 此前會報錯,因為認為 unknown 包含的類型信息更多 v2 = v1; ```

同時,對於 {},4.8 版本會將使用 {} 的交叉類型,如 obj & {} 直接簡化為 obj 類型,前提是 obj 並非來自於泛型,且非 null / undefined。這是因為交叉類型要求同時滿足兩個類型,而只要 obj 不是 null / undefined 類型,就可以認為必定也符合 {} 類型,因此可以直接將 {} 從交叉類型中移除:

typescript type T1 = {} & string;  // string type T2 = {} & 'linbudu';  // 'linbudu' type T3 = {} & object;  // object type T4 = {} & { x: number };  // { x: number } type T5 = {} & null;  // never type T6 = {} & undefined;  // never

而基於這一改動,你現在可以使用 {} 來剔除原類型中的 null 與 undefined,即原本的內置工具類型 NonNullable 實現會被更改為以下這種:

typescript type _NonNullable<T> = T extends null | undefined ? never : T; type NonNullable<T> = T & {};

實現原理則是 null & {}undefined & {} 會直接被判斷為 never ,從而消失在聯合類型結果中。

從 NonNullable 的實現變化我們可以知道,現在如果一個值不是 null 也不是 undefined ,那麼它的值其實就等於它和 {} 進行交叉的值,也就説我們能寫出以下的代碼:

typescript function throwIfNullable<T>(value: T): NonNullable<T> {    if (value === undefined || value === null) {        throw Error("Nullable value!");   } ​    return value; }

在過去,這個例子會拋出一個錯誤:類型 T 不能賦值給 NonNullable<T> 的類型,而現在我們知道如果剔除了 null 與 undefined ,那麼 T 其實等價於 T & {},也即 NonNullable<T>

最後,由於這些改動,現在 TypeScript 的類型控制流分析也得到了進一步的增強,現在 unknown 類型的變量會被完全視為 {} | null | undefined,因此會在 if else 的 truthy 分支中被收窄為 {}

typescript function narrowUnknown(x: unknown) {    if (x) {        x;  // {}   }    else {        x;  // unknown   } }

模板字符串類型中的 infer 提取

在 4.7 版本中 TypeScript 支持了 infer extends 語法,使得我們可以直接一步就 infer 到預期類型的值,而不需要再次進行條件語句判斷:

typescript type FirstString<T> =    T extends [infer S, ...unknown[]]        ? S extends string ? S : never       : never; ​ // 基於 infer extends type FirstString<T> =    T extends [infer S extends string, ...unknown[]]        ? S       : never;

4.8 版本在此基礎上進行了進一步地增強,當 infer 被約束為一個原始類型,那麼它現在會盡可能將 infer 的類型信息推導到字面量類型的級別:

``typescript // 此前為 number,現在為 '100' type SomeNum = "100" extends${infer U extends number}` ? U : never;

// 此前為 boolean,現在為 'true' type SomeBool = "true" extends ${infer U extends boolean} ? U : never; ```

同時,TypeScript 會檢查提取出的值能否重新映射回初始的字符串,如 SomeNum 中會檢查 String(Number("100")) 是否等於 "100",在下面這個例子中就是因為無法重新映射回去,而導致只能推導到 number 類型:

typescript // String(Number("1.0")) → "1",≠ "1.0" type JustNumber = "1.0" extends `${infer T extends number}` ? T : never;

綁定類型中的類型推導

TypeScript 中的泛型填充也會受到其調用方的影響,如以下示例:

```typescript declare function chooseRandomly(x: T,): T;

const res1 = chooseRandomly(["linbudu", 599, false]); ```

此時 res1 的類型與函數的泛型 T 會被推導為 Array<string | number | boolean>,但如果我們換一個方法:

typescript declare function chooseRandomly<T>(x: T,): T; ​ const [a, b, c] = chooseRandomly(["linbudu", 599, false]);

此時 a、b、c 被推導為了 string、number、boolean 類型,也就是説此時函數的泛型被填充為 [string, number, boolean] 這麼個元組類型。

這一泛型填充方式被稱為綁定模式(Binding Pattern),而在 4.8 版本中,禁用了基於綁定模式的類型推導,因為其對泛型的影響並不始終正確:

typescript declare function f<T>(x?: T): T; ​ const [x, y, z] = f();

在這一例子中,[x, y, z] 的綁定模式強行將泛型參數填充為了 [any, any, any],而這是非常不合理的——你怎麼能確定我是個數組結構?

對象字面量值與數組字面量值的全等比較提示

我們知道在 JavaScript 中 {} === {} 是不成立的,因為對象使用引用地址存儲,而這實際上是在比較兩個不同的引用地址。為了更好地避免代碼中錯誤使用 === 來比較對象和數組類型,TypeScript 現在會進行錯誤提示:

typescript const obj = {}; ​ // 此語句始終將返回 false,因為 JavaScript 中使用引用地址比較對象,而非實際值 if(obj === {}){   }

類似的,此前如果你在 if 語句中錯誤地使用了函數,TypeScript 也會給你一個提示:

typescript const func = () => {}; ​ // 此表達式將始終返回 true,你是否想要調用 func ? if(func) { }

Compiler 優化

4.8 版本還對 tsc 進行了一些性能優化工作,包括監聽模式 --watch,增量構建 --incremental 以及 Project References 下的 --build 模式。如現在監聽模式下,會跳過那些不是因為用户操作而導致變更的文件。

你可以閲讀 #48784 瞭解更多優化信息。

破壞性變更

  • lib.d.ts 更新

  • JavaScript 文件中不再允許對類型的導入,此前你可以導入一個類型來作為 JSDoc 描述:

    JavaScript import { IConfiguration } from 'foo'; ​ /** * @type {IConfiguration} */ export const config = {};

    這一使用方式現在會拋出一個錯誤,實際上,更常見的方式是這樣的:

    /** * @type {import("foo").IConfiguration} */ export const config = {}; ​ // CommonJs 下: module.exports = /** @type {import("foo").IConfiguration} */ {}

    而對於 JavaScript 文件中的類型導出,你可以使用 @typedef 來聲明一個類型導出:

    JavaScript /** * @typedef {string | number} MyType */ export { MyType } ​ // 現在的使用 /** * @typedef {string | number} FooType */

全文完,我們 4.9 beta 版本見:-)。