satisfies 運算子
TypeScript 開發者經常面臨一個困境:我們既想確保某個表示式符合某種型別,又想保留該表示式的最具體型別以用於型別推斷。
例如:
ts// Each property can be a string or an RGB tuple.const palette = {red: [255, 0, 0],green: "#00ff00",bleu: [0, 0, 255]// ^^^^ sacrebleu - we've made a typo!};// We want to be able to use string methods on 'green'...const greenNormalized = palette.green.toUpperCase();
請注意,我們寫成了 bleu,但可能本應寫成 blue。我們可以嘗試透過對 palette 使用型別註解來捕捉 bleu 這個拼寫錯誤,但這樣我們會丟失每個屬性的具體資訊。
tstype Colors = "red" | "green" | "blue";type RGB = [red: number, green: number, blue: number];const palette: Record<Colors, string | RGB> = {red: [255, 0, 0],green: "#00ff00",bleu: [0, 0, 255]// ~~~~ The typo is now correctly detected};// But we now have an undesirable error here - 'palette.green' "could" be of type RGB and// property 'toUpperCase' does not exist on type 'string | RGB'.const greenNormalized = palette.green.toUpperCase();
新的 satisfies 運算子允許我們驗證表示式的型別是否符合某種型別,而不會更改該表示式的結果型別。例如,我們可以使用 satisfies 來驗證 palette 的所有屬性是否與 string | number[] 相容。
tstype Colors = "red" | "green" | "blue";type RGB = [red: number, green: number, blue: number];const palette = {red: [255, 0, 0],green: "#00ff00",bleu: [0, 0, 255]// ~~~~ The typo is now caught!} satisfies Record<Colors, string | RGB>;// toUpperCase() method is still accessible!const greenNormalized = palette.green.toUpperCase();
satisfies 可用於捕獲許多潛在錯誤。例如,我們可以確保物件擁有某種型別的所有鍵,且沒有多餘的鍵。
tstype Colors = "red" | "green" | "blue";// Ensure that we have exactly the keys from 'Colors'.const favoriteColors = {"red": "yes","green": false,"blue": "kinda","platypus": false// ~~~~~~~~~~ error - "platypus" was never listed in 'Colors'.} satisfies Record<Colors, unknown>;// All the information about the 'red', 'green', and 'blue' properties are retained.const g: boolean = favoriteColors.green;
也許我們不在意屬性名是否完全匹配,但我們在意每個屬性的型別。在這種情況下,我們也可以確保物件的所有屬性值都符合某種型別。
tstype RGB = [red: number, green: number, blue: number];const palette = {red: [255, 0, 0],green: "#00ff00",blue: [0, 0]// ~~~~~~ error!} satisfies Record<string, string | RGB>;// Information about each property is still maintained.const redComponent = palette.red.at(0);const greenNormalized = palette.green.toUpperCase();
更多示例,請參閱提議此功能的 issue 和 實現該功能的 pull request。我們衷心感謝 Oleksandr Tarasiuk,他與我們一起實現了這一功能並進行了迭代。
使用 in 運算子進行未列出屬性的型別收窄
作為開發者,我們經常需要處理在執行時並不完全確定的值。事實上,無論是從伺服器接收響應還是讀取配置檔案,我們經常不知道屬性是否存在。JavaScript 的 in 運算子可以檢查某個屬性是否存在於物件中。
以前,TypeScript 允許我們收窄(narrow)掉任何未明確列出該屬性的型別。
tsinterface RGB {red: number;green: number;blue: number;}interface HSV {hue: number;saturation: number;value: number;}function setColor(color: RGB | HSV) {if ("hue" in color) {// 'color' now has the type HSV}// ...}
在這裡,型別 RGB 沒有列出 hue,因此被收窄剔除,只剩下型別 HSV。
但是,如果沒有任何型別列出某個給定的屬性呢?在這種情況下,語言並沒有提供太多幫助。讓我們看下面這個 JavaScript 示例。
jsfunction tryGetPackageName(context) {const packageJSON = context.packageJSON;// Check to see if we have an object.if (packageJSON && typeof packageJSON === "object") {// Check to see if it has a string name property.if ("name" in packageJSON && typeof packageJSON.name === "string") {return packageJSON.name;}}return undefined;}
將其改寫為標準的 TypeScript 只需為 context 定義並使用一個型別;然而,為 packageJSON 屬性選擇一個安全的型別(如 unknown)會在舊版本的 TypeScript 中引發問題。
tsinterface Context {packageJSON: unknown;}function tryGetPackageName(context: Context) {const packageJSON = context.packageJSON;// Check to see if we have an object.if (packageJSON && typeof packageJSON === "object") {// Check to see if it has a string name property.if ("name" in packageJSON && typeof packageJSON.name === "string") {// ~~~~// error! Property 'name' does not exist on type 'object.return packageJSON.name;// ~~~~// error! Property 'name' does not exist on type 'object.}}return undefined;}
這是因為雖然 packageJSON 的型別從 unknown 收窄到了 object,但 in 運算子嚴格地收窄至那些實際定義了所檢查屬性的型別。結果,packageJSON 的型別依然保持為 object。
TypeScript 4.9 使 in 運算子在收窄那些根本沒有列出該屬性的型別時變得更加強大。語言不再保持其原樣,而是將它們的型別與 Record<"property-key-being-checked", unknown> 進行交叉(intersect)。
所以,在我們的示例中,packageJSON 的型別將從 unknown 收窄為 object,進而收窄為 object & Record<"name", unknown>。這使我們可以直接訪問 packageJSON.name 並對其進行獨立的收窄。
tsinterface Context {packageJSON: unknown;}function tryGetPackageName(context: Context): string | undefined {const packageJSON = context.packageJSON;// Check to see if we have an object.if (packageJSON && typeof packageJSON === "object") {// Check to see if it has a string name property.if ("name" in packageJSON && typeof packageJSON.name === "string") {// Just works!return packageJSON.name;}}return undefined;}
TypeScript 4.9 還收緊了對 in 使用方式的一些檢查,確保左側可賦值給 string | number | symbol 型別,右側可賦值給 object。這有助於檢查我們是否使用了有效的屬性鍵,而不是意外地檢查了原始型別。
欲瞭解更多資訊,請閱讀實現該功能的 pull request。
類中的自動訪問器 (Auto-Accessors)
TypeScript 4.9 支援 ECMAScript 即將推出的一項特性,即自動訪問器(auto-accessors)。自動訪問器的宣告方式與類中的屬性相同,只是使用了 accessor 關鍵字。
tsclass Person {accessor name: string;constructor(name: string) {this.name = name;}}
在底層,這些自動訪問器會“去糖”為帶有不可訪問私有屬性的 get 和 set 訪問器。
tsclass Person {#__name: string;get name() {return this.#__name;}set name(value: string) {this.#__name = value;}constructor(name: string) {this.name = name;}}
你可以在原始 PR 中閱讀更多關於自動訪問器 pull request 的資訊。
針對 NaN 的相等性檢查
JavaScript 開發者的一個主要坑點是使用內建的相等運算子來檢查 NaN 值。
作為背景,NaN 是一個特殊的數值,代表“非數字”(Not a Number)。沒有任何東西等於 NaN —— 即使是 NaN 本身!
jsconsole.log(NaN == 0) // falseconsole.log(NaN === 0) // falseconsole.log(NaN == NaN) // falseconsole.log(NaN === NaN) // false
但至少對稱地,任何東西總是與 NaN 不相等。
jsconsole.log(NaN != 0) // trueconsole.log(NaN !== 0) // trueconsole.log(NaN != NaN) // trueconsole.log(NaN !== NaN) // true
這在技術上並非 JavaScript 特有的問題,因為任何包含 IEEE-754 浮點數的語言都具有相同的行為;但 JavaScript 的主要數值型別是浮點數,且在 JavaScript 中解析數字經常會導致 NaN。因此,檢查 NaN 變得相當普遍,而正確的方法是使用 Number.isNaN —— 但正如我們提到的,許多人會不小心使用 someValue === NaN 來檢查。
現在,TypeScript 對與 NaN 的直接比較報錯,並建議改用 Number.isNaN 的某種變體。
tsfunction validate(someValue: number) {return someValue !== NaN;// ~~~~~~~~~~~~~~~~~// error: This condition will always return 'true'.// Did you mean '!Number.isNaN(someValue)'?}
我們認為這一改變將嚴格有助於捕捉新手錯誤,類似於 TypeScript 目前對物件和陣列字面量比較發出的錯誤警告。
我們衷心感謝 Oleksandr Tarasiuk,他貢獻了這一檢查功能。
檔案監控現已使用檔案系統事件
在早期版本中,TypeScript 在監控單個檔案時嚴重依賴輪詢(polling)。使用輪詢策略意味著定期檢查檔案的狀態以檢視更新。在 Node.js 上,fs.watchFile 是獲取輪詢檔案監控器的內建方式。雖然輪詢在跨平臺和檔案系統方面往往更可預測,但這意味著你的 CPU 必須定期被中斷以檢查檔案更新,即使沒有任何變化。對於幾十個檔案,這可能不明顯;但對於大型專案——或 node_modules 中有大量檔案——這可能會成為資源消耗大戶。
總的來說,更好的方法是使用檔案系統事件。無需輪詢,我們可以宣告我們要監控特定檔案的更新,並提供一個在這些檔案確實發生變化時的回撥。大多數現代平臺都提供如 CreateIoCompletionPort、kqueue、epoll 和 inotify 等設施和 API。Node.js 主要透過提供 fs.watch 來抽象這些 API。檔案系統事件通常工作得很好,但在使用 fs.watch API 時有許多注意事項。監控器需要仔細考慮 inode 監控、某些檔案系統上的不可用性(例如網路檔案系統)、遞迴檔案監控是否可用、目錄重新命名是否觸發事件,甚至檔案監控器耗盡!換句話說,這並非免費午餐,尤其是如果你追求跨平臺的情況下。
因此,我們的預設選擇是最小公分母:輪詢。雖然不是總是如此,但大多數情況下是這樣的。
隨著時間的推移,我們提供了選擇其他檔案監控策略的手段。這使我們能夠收集反饋並加強我們的檔案監控實現,以應對大多數這些特定於平臺的陷阱。隨著 TypeScript 需要擴充套件到更大的程式碼庫,並且在該領域有所改進,我們認為預設切換到檔案系統事件將是一項值得的投資。
在 TypeScript 4.9 中,檔案監控預設由檔案系統事件驅動,僅在無法設定基於事件的監控器時才回退到輪詢。對於大多數開發者來說,這應該能在執行 --watch 模式或在 Visual Studio 或 VS Code 等 TypeScript 驅動的編輯器中執行時,提供更少資源消耗的體驗。
檔案監控的工作方式仍然可以透過環境變數和 watchOptions 進行配置——並且像 VS Code 這樣的一些編輯器可以獨立支援 watchOptions。原始碼駐留在網路檔案系統(如 NFS 和 SMB)上的開發者可能需要切換回舊的行為;儘管如果伺服器有足夠的處理能力,啟用 SSH 並遠端執行 TypeScript 以便其具有直接的本地檔案訪問許可權可能更好。VS Code 有大量遠端擴充套件來簡化此操作。
你可以在 GitHub 上閱讀有關此更改的更多資訊。
編輯器的“刪除未使用的匯入”和“排序匯入”命令
以前,TypeScript 僅支援兩個管理匯入的編輯器命令。對於我們的示例,請看以下程式碼。
tsimport { Zebra, Moose, HoneyBadger } from "./zoo";import { foo, bar } from "./helper";let x: Moose | HoneyBadger = foo();
第一個稱為“組織匯入”(Organize Imports),它會刪除未使用的匯入,然後對剩餘的匯入進行排序。它會將該檔案重寫為如下所示。
tsimport { foo } from "./helper";import { HoneyBadger, Moose } from "./zoo";let x: Moose | HoneyBadger = foo();
在 TypeScript 4.3 中,我們引入了一個名為“排序匯入”(Sort Imports)的命令,它僅對檔案中的匯入進行排序,而不刪除它們——並會將檔案重寫為如下所示。
tsimport { bar, foo } from "./helper";import { HoneyBadger, Moose, Zebra } from "./zoo";let x: Moose | HoneyBadger = foo();
“排序匯入”的一個注意事項是,在 Visual Studio Code 中,此功能僅作為“儲存時”命令提供,而不是作為可手動觸發的命令。
TypeScript 4.9 添加了另一半功能,現在提供了“刪除未使用的匯入”(Remove Unused Imports)。TypeScript 現在將刪除未使用的匯入名稱和語句,但在其他方面保持相對順序不變。
tsimport { Moose, HoneyBadger } from "./zoo";import { foo } from "./helper";let x: Moose | HoneyBadger = foo();
此功能適用於所有希望使用這兩個命令的編輯器;但值得注意的是,Visual Studio Code(1.73 及更高版本)將內建支援並透過其命令面板(Command Palette)顯示這些命令。喜歡使用更細粒度的“刪除未使用的匯入”或“排序匯入”命令的使用者,如果需要,可以將“組織匯入”的快捷鍵組合重新分配給它們。
你可以在此處檢視該功能的細節。
在 return 關鍵字上跳轉到定義
在編輯器中,當在 return 關鍵字上執行“跳轉到定義”時,TypeScript 現在會讓你跳轉到相應函式的頂部。這有助於快速瞭解 return 屬於哪個函式。
我們預計 TypeScript 會將此功能擴充套件到更多關鍵字,例如 await 和 yield,或者switch、case 和 default。
此功能得以實現要感謝 Oleksandr Tarasiuk。
效能改進
TypeScript 包含一些雖小但顯著的效能改進。
首先,TypeScript 的 forEachChild 函式已被重寫,使用函式表查詢來代替跨所有語法節點的 switch 語句。forEachChild 是編譯器中遍歷語法節點的功臣,在編譯器的繫結階段以及語言服務的多個部分被大量使用。forEachChild 的重構使我們的繫結階段和語言服務操作的時間減少了多達 20%。
在我們發現 forEachChild 的效能提升後,我們將其嘗試應用於 visitEachChild,這是我們用於在編譯器和語言服務中轉換節點的函式。同樣的重構使生成專案輸出的時間減少了多達 3%。
forEachChild 的初步探索受到 Artemis Everfree 一篇博文的啟發。雖然我們有理由相信效能提升的根本原因可能與函式大小/複雜性有關,而不是博文中描述的問題,但我們很感謝能夠從中學習並嘗試了一次相對快速的重構,從而使 TypeScript 執行得更快。
最後,TypeScript 在條件型別(conditional type)的 true 分支中儲存型別資訊的方式得到了最佳化。在如下型別中:
tsinterface Zoo<T extends Animal> {// ...}type MakeZoo<A> = A extends Animal ? Zoo<A> : never;
在檢查 Zoo<A> 是否有效時,TypeScript 必須“記住” A 也必須是 Animal。這基本上是透過建立一個特殊的型別來完成的,該型別過去用於儲存 A 與 Animal 的交集;然而,TypeScript 之前是急切地執行此操作的,這並不總是必要的。此外,我們型別檢查器中的一些錯誤程式碼阻止了這些特殊型別被簡化。現在,TypeScript 會延遲這些型別的交集運算,直到必要時才進行。對於大量使用條件型別的程式碼庫,你可能會見證 TypeScript 的顯著速度提升,但在我們的效能測試套件中,我們觀察到型別檢查時間有了較溫和的 3% 減少。
你可以在各自的 pull request 中閱讀有關這些最佳化的更多資訊。
正確性修復與破壞性更改
lib.d.ts 更新
雖然 TypeScript 努力避免重大破壞,但即使是內建庫中的小改動也可能導致問題。我們預計 DOM 和 lib.d.ts 更新不會導致重大破壞,但可能會有一些微小的破壞。
為 Promise.resolve 提供更好的型別
Promise.resolve 現在使用 Awaited 型別來解包傳遞給它的 Promise 類型別。這意味著它更經常返回正確的 Promise 型別,但如果現有程式碼期望 any 或 unknown 而不是 Promise,這種改進後的型別可能會破壞現有程式碼。更多資訊,請參閱最初的更改。
JavaScript 發出(emit)不再忽略匯入
當 TypeScript 最初支援 JavaScript 的型別檢查和編譯時,它無意中支援了一個稱為“匯入忽略”(import elision)的特性。簡而言之,如果一個匯入沒有作為值使用,或者編譯器可以檢測到該匯入在執行時不引用值,那麼編譯器就會在輸出中刪除該匯入。
這種行為是有問題的,特別是對於“檢測匯入是否不引用值”這一機制,因為這意味著 TypeScript 必須信任有時不準確的宣告檔案。因此,TypeScript 現在在 JavaScript 檔案中保留匯入。
js// Input:import { someValue, SomeClass } from "some-module";/** @type {SomeClass} */let val = someValue;// Previous Output:import { someValue } from "some-module";/** @type {SomeClass} */let val = someValue;// Current Output:import { someValue, SomeClass } from "some-module";/** @type {SomeClass} */let val = someValue;
更多資訊可在實現該更改的 PR 中獲取。
exports 的優先順序高於 typesVersions
以前,當在 --moduleResolution node16 下透過 package.json 解析時,TypeScript 錯誤地將 typesVersions 欄位的優先順序置於 exports 欄位之上。如果此更改影響了你的庫,你可能需要在 package.json 的 exports 欄位中新增 types@ 版本選擇器。
diff{"type": "module","main": "./dist/main.js""typesVersions": {"<4.8": { ".": ["4.8-types/main.d.ts"] },"*": { ".": ["modern-types/main.d.ts"] }},"exports": {".": {+ "types@<4.8": "./4.8-types/main.d.ts",+ "types": "./modern-types/main.d.ts","import": "./dist/main.js"}}}
更多資訊,請參閱此 pull request。
SubstitutionType 上的 substitute 被 constraint 取代
作為對替換型別(substitution types)最佳化的一部分,SubstitutionType 物件不再包含表示有效替換的 substitute 屬性(通常是基型別和隱式約束的交集),而是隻包含 constraint 屬性。
更多詳情,請在原始 pull request 中閱讀。