可選鏈 (Optional Chaining)
可選鏈是我們在問題追蹤器上的 第 16 號議題。作為背景資訊,自那時起,TypeScript 問題追蹤器上已經有了超過 23,000 個議題。
其核心在於,可選鏈使我們能夠編寫這樣的程式碼:當遇到 null 或 undefined 時,TypeScript 可以立即停止執行某些表示式。可選鏈的主角是用於可選屬性訪問的全新 ?. 運算子。當我們編寫如下程式碼時:
tslet x = foo?.bar.baz();
這是一種表達方式:當 foo 已定義時,將計算 foo.bar.baz();但當 foo 為 null 或 undefined 時,停止當前操作並直接返回 undefined。”
簡單來說,該程式碼片段等同於編寫以下內容。
tslet x = foo === null || foo === undefined ? undefined : foo.bar.baz();
請注意,如果 bar 為 null 或 undefined,我們的程式碼在訪問 baz 時仍會報錯。同樣,如果 baz 為 null 或 undefined,我們在呼叫處也會遇到錯誤。?. 只會檢查其左側的值是否為 null 或 undefined,而不會檢查後續的任何屬性。
你可能會發現自己使用 ?. 替換了大量使用 && 運算子執行重複空值檢查的程式碼。
ts// Beforeif (foo && foo.bar && foo.bar.baz) {// ...}// After-ishif (foo?.bar?.baz) {// ...}
請記住,?. 的行為與那些 && 操作不同,因為 && 會對“假值”(例如空字串、0、NaN 以及 false)進行特殊處理,但這正是該結構的有意設計。它不會像 0 或空字串這樣的有效資料那樣進行短路。
可選鏈還包括另外兩個操作。首先是可選元素訪問,其作用類似於可選屬性訪問,但允許我們訪問非識別符號屬性(例如任意字串、數字和符號)。
ts/*** Get the first element of the array if we have an array.* Otherwise return undefined.*/function tryGetFirstElement<T>(arr?: T[]) {return arr?.[0];// equivalent to// return (arr === null || arr === undefined) ?// undefined :// arr[0];}
此外還有可選呼叫,它允許我們在表示式不為 null 或 undefined 時有條件地呼叫它們。
tsasync function makeRequest(url: string, log?: (msg: string) => void) {log?.(`Request started at ${new Date().toISOString()}`);// roughly equivalent to// if (log != null) {// log(`Request started at ${new Date().toISOString()}`);// }const result = (await fetch(url)).json();log?.(`Request finished at ${new Date().toISOString()}`);return result;}
可選鏈所具有的“短路”行為僅限於屬性訪問、呼叫和元素訪問——它不會在這些表示式之外進一步擴充套件。換句話說,
tslet result = foo?.bar / someComputation();
不會阻止除法運算或 someComputation() 的呼叫發生。它等同於
tslet temp = foo === null || foo === undefined ? undefined : foo.bar;let result = temp / someComputation();
這可能會導致除以 undefined,這就是為什麼在 strictNullChecks 下,以下程式碼會報錯。
tsfunction barPercentage(foo?: { bar: number }) {return foo?.bar / 100;// ~~~~~~~~// Error: Object is possibly undefined.}
空值合併 (Nullish Coalescing)
空值合併運算子是另一個即將推出的 ECMAScript 特性,它與可選鏈相輔相成,並且我們的團隊一直致力於在 TC39 中推廣它。
你可以將此特性(?? 運算子)視為在處理 null 或 undefined 時“回退”到預設值的一種方式。當我們編寫如下程式碼時:
tslet x = foo ?? bar();
這是一種新的表達方式:當 foo “存在”時使用該值;但當它為 null 或 undefined 時,改為計算 bar()。
同樣,上述程式碼等同於以下內容。
tslet x = foo !== null && foo !== undefined ? foo : bar();
當嘗試使用預設值時,?? 運算子可以替換 || 的使用。例如,以下程式碼片段嘗試獲取最後一次儲存在 localStorage 中的音量(如果有的話);然而,它存在一個 bug,因為它使用了 ||。
tsfunction initializeAudio() {let volume = localStorage.volume || 0.5;// ...}
當 localStorage.volume 被設定為 0 時,頁面會將音量設定為 0.5,這並非預期結果。?? 避免了因 0、NaN 和 "" 被視為假值而產生的一些意外行為。
我們衷心感謝社群成員 Wenlu Wang 和 Titian Cernicova Dragomir 實現此功能!欲瞭解更多詳情,請檢視他們的拉取請求和空值合併提案倉庫。
斷言函式 (Assertion Functions)
有一類特定的函式在發生意外情況時會 throw 錯誤。它們被稱為“斷言”函式。例如,Node.js 有一個專門用於此目的的函式,名為 assert。
jsassert(someValue === 42);
在此示例中,如果 someValue 不等於 42,那麼 assert 將丟擲 AssertionError。
JavaScript 中的斷言通常用於防範傳入不正確的型別。例如:
jsfunction multiply(x, y) {assert(typeof x === "number");assert(typeof y === "number");return x * y;}
不幸的是,在 TypeScript 中,這些檢查無法被正確編碼。對於弱型別程式碼,這意味著 TypeScript 的檢查力度變弱;而對於稍微保守的程式碼,它通常迫使使用者使用型別斷言。
tsfunction yell(str) {assert(typeof str === "string");return str.toUppercase();// Oops! We misspelled 'toUpperCase'.// Would be great if TypeScript still caught this!}
另一種替代方案是重寫程式碼以便語言可以對其進行分析,但這並不方便。
tsfunction yell(str) {if (typeof str !== "string") {throw new TypeError("str should have been a string.");}// Error caught!return str.toUppercase();}
歸根結底,TypeScript 的目標是以最不具破壞性的方式為現有的 JavaScript 結構進行型別定義。出於這個原因,TypeScript 3.7 引入了一個稱為“斷言簽名”的新概念,用於模擬這些斷言函式。
第一種斷言簽名模擬了 Node 的 assert 函式的工作方式。它確保在包含作用域的剩餘部分中,被檢查的任何條件都必須為真。
tsfunction assert(condition: any, msg?: string): asserts condition {if (!condition) {throw new AssertionError(msg);}}
asserts condition 表示如果 assert 返回(因為它否則會丟擲錯誤),那麼傳遞給 condition 引數的任何內容都必須為真。這意味著在該作用域的其餘部分,該條件必須是真值。作為一個例子,使用此斷言函式意味著我們確實捕獲了最初的 yell 示例。
tsfunction yell(str) {assert(typeof str === "string");return str.toUppercase();// ~~~~~~~~~~~// error: Property 'toUppercase' does not exist on type 'string'.// Did you mean 'toUpperCase'?}function assert(condition: any, msg?: string): asserts condition {if (!condition) {throw new AssertionError(msg);}}
另一種型別的斷言簽名不檢查條件,而是告訴 TypeScript 某個特定的變數或屬性具有不同的型別。
tsfunction assertIsString(val: any): asserts val is string {if (typeof val !== "string") {throw new AssertionError("Not a string!");}}
這裡 asserts val is string 確保在任何呼叫 assertIsString 之後,傳入的任何變數都將被識別為 string。
tsfunction yell(str: any) {assertIsString(str);// Now TypeScript knows that 'str' is a 'string'.return str.toUppercase();// ~~~~~~~~~~~// error: Property 'toUppercase' does not exist on type 'string'.// Did you mean 'toUpperCase'?}
這些斷言簽名與編寫型別謂詞簽名非常相似:
tsfunction isString(val: any): val is string {return typeof val === "string";}function yell(str: any) {if (isString(str)) {return str.toUppercase();}throw "Oops!";}
而且就像型別謂詞簽名一樣,這些斷言簽名也非常具有表現力。我們可以用它們表達一些相當複雜的想法。
tsfunction assertIsDefined<T>(val: T): asserts val is NonNullable<T> {if (val === undefined || val === null) {throw new AssertionError(`Expected 'val' to be defined, but received ${val}`);}}
若要詳細瞭解斷言簽名,請檢視原始拉取請求。
對返回 never 的函式提供更好的支援
作為斷言簽名工作的一部分,TypeScript 需要對函式在何處以及如何被呼叫進行更多的編碼。這給了我們擴充套件另一類函式支援的機會:返回 never 的函式。
任何返回 never 的函式的意圖都是它永遠不會返回。它表明丟擲了異常、發生了停止錯誤條件,或者程式已退出。例如,@types/node 中的 process.exit(...) 被指定為返回 never。
為了確保函式永遠不會潛在地返回 undefined 或在所有程式碼路徑中有效地返回,TypeScript 需要一些語法訊號——即在函式末尾使用 return 或 throw。因此,使用者經常發現自己需要 return 他們的失敗處理函式。
tsfunction dispatch(x: string | number): SomeType {if (typeof x === "string") {return doThingWithString(x);} else if (typeof x === "number") {return doThingWithNumber(x);}return process.exit(1);}
現在,當呼叫這些返回 never 的函式時,TypeScript 會識別出它們影響控制流圖並將其納入考量。
tsfunction dispatch(x: string | number): SomeType {if (typeof x === "string") {return doThingWithString(x);} else if (typeof x === "number") {return doThingWithNumber(x);}process.exit(1);}
與斷言函式一樣,你可以在同一個拉取請求中瞭解更多資訊。
(更多)遞迴類型別名
類型別名在“遞迴”引用自身的方式上一直存在限制。原因是任何使用類型別名的地方都需要能夠用它所別名的內容進行替換。在某些情況下,這是不可能的,因此編譯器會拒絕某些遞迴別名,例如以下內容:
tstype Foo = Foo;
這是一個合理的限制,因為 Foo 的任何使用都需要被替換為 Foo,進而需要被替換為 Foo,再被替換為 Foo,等等……好吧,希望你明白了!最終,沒有一種型別可以在 Foo 的位置上有意義。
這與其他語言處理類型別名的方式相當一致,但它確實導致了一些使用者利用該特性時略顯驚奇的場景。例如,在 TypeScript 3.6 及之前版本中,以下內容會導致錯誤。
tstype ValueOrArray<T> = T | Array<ValueOrArray<T>>;// ~~~~~~~~~~~~// error: Type alias 'ValueOrArray' circularly references itself.
這很奇怪,因為嚴格來說,使用者始終可以透過引入介面來編寫本質上相同的程式碼,並沒有任何問題。
tstype ValueOrArray<T> = T | ArrayOfValueOrArray<T>;interface ArrayOfValueOrArray<T> extends Array<ValueOrArray<T>> {}
因為介面(和其他物件型別)引入了一層間接性,並且不需要立即完整構建其結構,TypeScript 在處理這種結構時沒有問題。
但是,引入介面的變通方法對使用者來說並不直觀。原則上,直接使用 Array 的原始版 ValueOrArray 確實沒有任何問題。如果編譯器稍微“懶”一點,只在必要時計算 Array 的型別引數,那麼 TypeScript 就能正確表達這些內容。
這正是 TypeScript 3.7 所引入的。在類型別名的“頂層”,TypeScript 將推遲解析型別引數以允許這些模式。
這意味著如下嘗試表示 JSON 的程式碼……
tstype Json = string | number | boolean | null | JsonObject | JsonArray;interface JsonObject {[property: string]: Json;}interface JsonArray extends Array<Json> {}
最終無需輔助介面即可重寫。
tstype Json =| string| number| boolean| null| { [property: string]: Json }| Json[];
這種新的放寬限制還讓我們能夠在元組中遞迴引用類型別名。以下過去會報錯的程式碼現在是有效的 TypeScript 程式碼。
tstype VirtualNode = string | [string, { [key: string]: any }, ...VirtualNode[]];const myNode: VirtualNode = ["div",{ id: "parent" },["div", { id: "first-child" }, "I'm the first child"],["div", { id: "second-child" }, "I'm the second child"],];
有關更多資訊,你可以閱讀原始拉取請求。
--declaration 和 --allowJs
TypeScript 中的 declaration 標誌允許我們從 TypeScript 原始檔(即 .ts 和 .tsx 檔案)生成 .d.ts 檔案(宣告檔案)。這些 .d.ts 檔案在幾個方面都很重要。
首先,它們之所以重要,是因為它們允許 TypeScript 在不重新檢查原始原始碼的情況下針對其他專案進行型別檢查。它們也很重要,因為它們允許 TypeScript 與現有的、在構建時未考慮 TypeScript 的 JavaScript 庫進行互操作。最後,還有一個通常被低估的好處:當使用由 TypeScript 驅動的編輯器時,TypeScript 和 JavaScript 使用者都能從這些檔案中受益,從而獲得更好的自動補全等功能。
不幸的是,declaration 並不支援 allowJs 標誌,後者允許混合使用 TypeScript 和 JavaScript 輸入檔案。這是一個令人沮喪的限制,因為這意味著使用者在遷移程式碼庫時無法使用 declaration 標誌,即使他們使用了 JSDoc 註釋。TypeScript 3.7 改變了這一點,允許這兩個選項同時使用!
此特性最顯著的影響可能比較微妙:透過 TypeScript 3.7,使用者可以使用帶有 JSDoc 註釋的 JavaScript 編寫庫,併為 TypeScript 使用者提供支援。
其工作原理是,在使用 allowJs 時,TypeScript 會進行一些盡力而為的分析來理解常見的 JavaScript 模式;然而,某些模式在 JavaScript 中的表達方式並不一定看起來像它們在 TypeScript 中的對應形式。當開啟 declaration 發出時,TypeScript 會找出最佳方式,將 JSDoc 註釋和 CommonJS 匯出轉換為輸出 .d.ts 檔案中的有效型別宣告等。
例如,以下程式碼片段:
jsconst assert = require("assert");module.exports.blurImage = blurImage;/*** Produces a blurred image from an input buffer.** @param input {Uint8Array}* @param width {number}* @param height {number}*/function blurImage(input, width, height) {const numPixels = width * height * 4;assert(input.length === numPixels);const result = new Uint8Array(numPixels);// TODOreturn result;}
將生成如下的 .d.ts 檔案:
ts/*** Produces a blurred image from an input buffer.** @param input {Uint8Array}* @param width {number}* @param height {number}*/export function blurImage(input: Uint8Array,width: number,height: number): Uint8Array;
這也可以超越帶有 @param 標籤的基礎函式,如下示例:
js/*** @callback Job* @returns {void}*//** Queues work */export class Worker {constructor(maxDepth = 10) {this.started = false;this.depthLimit = maxDepth;/*** NOTE: queued jobs may add more items to queue* @type {Job[]}*/this.queue = [];}/*** Adds a work item to the queue* @param {Job} work*/push(work) {if (this.queue.length + 1 > this.depthLimit) throw new Error("Queue full!");this.queue.push(work);}/*** Starts the queue if it has not yet started*/start() {if (this.started) return false;this.started = true;while (this.queue.length) {/** @type {Job} */ (this.queue.shift())();}return true;}}
將被轉換為以下 .d.ts 檔案:
ts/*** @callback Job* @returns {void}*//** Queues work */export class Worker {constructor(maxDepth?: number);started: boolean;depthLimit: number;/*** NOTE: queued jobs may add more items to queue* @type {Job[]}*/queue: Job[];/*** Adds a work item to the queue* @param {Job} work*/push(work: Job): void;/*** Starts the queue if it has not yet started*/start(): boolean;}export type Job = () => void;
請注意,當同時使用這些標誌時,TypeScript 不一定非要對 .js 檔案進行降級處理。如果你只是想讓 TypeScript 建立 .d.ts 檔案,可以使用 emitDeclarationOnly 編譯器選項。
欲瞭解更多詳情,請檢視原始拉取請求。
useDefineForClassFields 標誌和 declare 屬性修飾符
早在 TypeScript 實現公共類欄位時,我們盡最大努力假設以下程式碼:
tsclass C {foo = 100;bar: string;}
等同於建構函式體內類似的賦值。
tsclass C {constructor() {this.foo = 100;}}
遺憾的是,雖然這似乎是該提案早期階段發展的方向,但公共類欄位以不同方式標準化的可能性非常大。相反,原始程式碼示例可能需要脫糖為更接近以下內容的形式:
tsclass C {constructor() {Object.defineProperty(this, "foo", {enumerable: true,configurable: true,writable: true,value: 100,});Object.defineProperty(this, "bar", {enumerable: true,configurable: true,writable: true,value: void 0,});}}
雖然 TypeScript 3.7 預設不會改變任何現有的輸出,但我們一直在逐步推出變更以幫助使用者減輕未來潛在的中斷。我們提供了一個名為 useDefineForClassFields 的新標誌來啟用此輸出模式以及一些新的檢查邏輯。
兩個最大的變化如下:
- 宣告使用
Object.defineProperty進行初始化。 - 宣告始終初始化為
undefined,即使它們沒有初始化表示式。
這可能會對現有使用繼承的程式碼造成相當大的影響。首先,基類中的 set 訪問器將不會被觸發——它們將被完全覆蓋。
tsclass Base {set data(value: string) {console.log("data changed to " + value);}}class Derived extends Base {// No longer triggers a 'console.log'// when using 'useDefineForClassFields'.data = 10;}
其次,使用類欄位來專門化基類屬性也將無法工作。
tsinterface Animal {animalStuff: any;}interface Dog extends Animal {dogStuff: any;}class AnimalHouse {resident: Animal;constructor(animal: Animal) {this.resident = animal;}}class DogHouse extends AnimalHouse {// Initializes 'resident' to 'undefined'// after the call to 'super()' when// using 'useDefineForClassFields'!resident: Dog;constructor(dog: Dog) {super(dog);}}
這兩點歸結起來就是,混合屬性和訪問器會導致問題,重新宣告沒有初始化表示式的屬性也會導致問題。
為了檢測圍繞訪問器的問題,TypeScript 3.7 現在會在 .d.ts 檔案中發出 get/set 訪問器,以便 TypeScript 可以檢查被覆蓋的訪問器。
受類欄位變更影響的程式碼可以透過將欄位初始化表示式轉換為建構函式體內的賦值來規避該問題。
tsclass Base {set data(value: string) {console.log("data changed to " + value);}}class Derived extends Base {constructor() {this.data = 10;}}
為了幫助減輕第二個問題,你可以新增顯式初始化表示式,或者新增 declare 修飾符來表明該屬性不應有任何輸出。
tsinterface Animal {animalStuff: any;}interface Dog extends Animal {dogStuff: any;}class AnimalHouse {resident: Animal;constructor(animal: Animal) {this.resident = animal;}}class DogHouse extends AnimalHouse {declare resident: Dog;// ^^^^^^^// 'resident' now has a 'declare' modifier,// and won't produce any output code.constructor(dog: Dog) {super(dog);}}
目前 useDefineForClassFields 僅在以 ES5 及更高版本為目標時可用,因為 Object.defineProperty 在 ES3 中不存在。為了實現類似的檢查,你可以建立一個單獨的專案,以 ES5 為目標並使用 noEmit 來避免完整構建。
有關更多資訊,你可以檢視這些變更的原始拉取請求。
我們強烈建議使用者嘗試 useDefineForClassFields 標誌並向我們的問題追蹤器或下方的評論中反饋。這包括關於採用該標誌的難易程度的反饋,以便我們瞭解如何讓遷移變得更容易。
使用專案引用的無構建編輯
TypeScript 的專案引用為我們提供了一種簡單的方法來拆分程式碼庫以實現更快的編譯。遺憾的是,編輯一個其依賴項尚未構建(或其輸出已過時)的專案意味著編輯體驗效果不佳。
在 TypeScript 3.7 中,當開啟一個帶有依賴項的專案時,TypeScript 將自動改用原始碼 .ts/.tsx 檔案。這意味著使用專案引用的專案現在將獲得更好的編輯體驗,語義操作是最新的並且能夠“直接工作”。你可以使用編譯器選項 disableSourceOfProjectReferenceRedirect 停用此行為,在處理此變更可能影響編輯效能的超大型專案時,這可能很有用。
未呼叫函式檢查
一個常見且危險的錯誤是忘記呼叫函式,特別是如果該函式有零個引數或以暗示它可能是屬性而不是函式的方式命名時。
tsinterface User {isAdministrator(): boolean;notify(): void;doNotDisturb?(): boolean;}// later...// Broken code, do not use!function doAdminThing(user: User) {// oops!if (user.isAdministrator) {sudo();editTheConfiguration();} else {throw new AccessDeniedError("User is not an admin");}}
在這裡,我們忘記了呼叫 isAdministrator,而程式碼錯誤地允許非管理員使用者編輯配置!
在 TypeScript 3.7 中,這被識別為一個可能的錯誤。
tsfunction doAdminThing(user: User) {if (user.isAdministrator) {// ~~~~~~~~~~~~~~~~~~~~// error! This condition will always return true since the function is always defined.// Did you mean to call it instead?
此檢查是一個重大變更,但正因如此,檢查非常保守。此錯誤僅在 if 條件中發出,並且如果 strictNullChecks 已關閉,或者函式稍後在 if 的主體中被呼叫,則不會發出此錯誤。
tsinterface User {isAdministrator(): boolean;notify(): void;doNotDisturb?(): boolean;}function issueNotification(user: User) {if (user.doNotDisturb) {// OK, property is optional}if (user.notify) {// OK, called the functionuser.notify();}}
如果你打算在不呼叫函式的情況下測試它,你可以更正它的定義以包含 undefined/null,或者使用 !! 編寫類似 if (!!user.isAdministrator) 的程式碼以表明這種強制轉換是有意的。
我們衷心感謝 GitHub 使用者 @jwbay,他主動建立了一個 概念驗證並進行迭代,為我們提供了當前版本。
TypeScript 檔案中的 // @ts-nocheck
TypeScript 3.7 允許我們在 TypeScript 檔案頂部新增 // @ts-nocheck 註釋以停用語義檢查。過去,此註釋僅在開啟 checkJs 的 JavaScript 原始檔中受支援,但我們已將支援範圍擴大到 TypeScript 檔案,以便讓所有使用者的遷移更加容易。
分號格式化程式選項
TypeScript 的內建格式化程式現在支援在由於 JavaScript 的自動分號插入 (ASI) 規則而使尾隨分號可選的位置進行分號插入和移除。此設定現已在 Visual Studio Code Insiders 中提供,並將於 Visual Studio 16.4 Preview 2 的工具選項選單中提供。
選擇“insert”或“remove”值也會影響自動匯入、提取型別以及由 TypeScript 服務提供的其他生成程式碼的格式。將設定保留為預設值“ignore”,會使生成的程式碼與當前檔案中檢測到的分號偏好相匹配。
3.7 重大變更
DOM 變更
lib.dom.d.ts 中的型別已更新。這些變更主要是與空值性相關的正確性修復,但具體影響最終取決於你的程式碼庫。
類欄位緩解措施
如上所述,TypeScript 3.7 在 .d.ts 檔案中發出 get/set 訪問器,這可能會對使用較舊版本 TypeScript(如 3.5 及之前版本)的消費者造成重大變更。TypeScript 3.6 的使用者將不會受到影響,因為該版本已為該功能進行了面向未來的準備。
雖然這本身不是一種破壞,但選擇使用 useDefineForClassFields 標誌可能會在以下情況下造成破壞:
- 在派生類中使用屬性宣告覆蓋訪問器
- 重新宣告沒有初始化表示式的屬性宣告
要了解全部影響,請閱讀關於 useDefineForClassFields 標誌的章節。
函式真值檢查
如上所述,TypeScript 現在會在 if 語句條件中出現未呼叫的函式時報錯。當在 if 條件中檢查函式型別時,除非應用以下任一情況,否則會發出錯誤:
- 被檢查的值來自可選屬性
strictNullChecks已停用- 該函式稍後在
if的主體中被呼叫
本地和匯入的型別宣告現在衝突
由於一個 bug,以下構造以前在 TypeScript 中是允許的:
ts// ./someOtherModule.tsinterface SomeType {y: string;}// ./myModule.tsimport { SomeType } from "./someOtherModule";export interface SomeType {x: number;}function fn(arg: SomeType) {console.log(arg.x); // Error! 'x' doesn't exist on 'SomeType'}
在這裡,SomeType 似乎既源自 import 宣告,也源自本地 interface 宣告。或許令人驚訝的是,在模組內部,SomeType 僅指代 import 匯入的定義,而本地宣告的 SomeType 僅在從另一個檔案匯入時才可用。這非常令人困惑,我們對在實際程式碼中發現的極少數此類案例進行了審查,結果顯示開發者通常認為發生了其他事情。
在 TypeScript 3.7 中,這現在被正確識別為重複識別符號錯誤。正確的修復取決於作者的初衷,應根據具體情況處理。通常,命名衝突是無意的,最好的修復方法是重新命名匯入的型別。如果意圖是增強匯入的型別,則應編寫適當的模組增強。
3.7 API 變更
為了啟用上述遞迴類型別名模式,typeArguments 屬性已從 TypeReference 介面中移除。使用者應改為使用 TypeChecker 例項上的 getTypeArguments 函式。