型別謂詞推斷
本節由 Dan Vanderkam 編寫,他在 TypeScript 5.5 中實現了此特性。感謝 Dan!
TypeScript 的控制流分析在跟蹤變數型別隨程式碼執行而變化方面表現出色。
tsxinterface Bird {commonName: string;scientificName: string;sing(): void;}// Maps country names -> national bird.// Not all nations have official birds (looking at you, Canada!)declare const nationalBirds: Map<string, Bird>;function makeNationalBirdCall(country: string) {const bird = nationalBirds.get(country); // bird has a declared type of Bird | undefinedif (bird) {bird.sing(); // bird has type Bird inside the if statement} else {// bird has type undefined here.}}
透過強制處理 undefined 的情況,TypeScript 推動你編寫更健壯的程式碼。
在過去,這種型別細化更難應用於陣列。在 TypeScript 的所有舊版本中,這都會導致錯誤。
tsxfunction makeBirdCalls(countries: string[]) {// birds: (Bird | undefined)[]const birds = countries.map(country => nationalBirds.get(country)).filter(bird => bird !== undefined);for (const bird of birds) {bird.sing(); // error: 'bird' is possibly 'undefined'.}}
這段程式碼完全沒有問題:我們已經從列表中過濾掉了所有 undefined 值。但 TypeScript 以前無法跟上這一邏輯。
在 TypeScript 5.5 中,型別檢查器可以正常處理這段程式碼。
tsxfunction makeBirdCalls(countries: string[]) {// birds: Bird[]const birds = countries.map(country => nationalBirds.get(country)).filter(bird => bird !== undefined);for (const bird of birds) {bird.sing(); // ok!}}
請注意 birds 更精確的型別。
之所以有效,是因為 TypeScript 現在為 filter 函式推斷出了一個型別謂詞。將其提取為獨立函式可以更清晰地看出發生了什麼:
tsx// function isBirdReal(bird: Bird | undefined): bird is Birdfunction isBirdReal(bird: Bird | undefined) {return bird !== undefined;}
bird is Bird 就是型別謂詞。它的意思是,如果函式返回 true,那麼它就是 Bird(如果函式返回 false,則它是 undefined)。Array.prototype.filter 的型別宣告能夠識別型別謂詞,因此最終結果是你得到了更精確的型別,並且程式碼通過了型別檢查。
如果滿足以下條件,TypeScript 將推斷函式返回一個型別謂詞:
- 函式沒有顯式的返回型別或型別謂詞註解。
- 函式有一個單一的
return語句,且沒有隱式返回。 - 函式沒有修改其引數。
- 函式返回一個與引數細化相關的
boolean表示式。
通常情況下,這符合預期。以下是型別謂詞推斷的更多示例。
tsx// const isNumber: (x: unknown) => x is numberconst isNumber = (x: unknown) => typeof x === 'number';// const isNonNullish: <T>(x: T) => x is NonNullable<T>const isNonNullish = <T,>(x: T) => x != null;
以前,TypeScript 只會推斷這些函式返回 boolean。現在,它會推斷出包含型別謂詞的簽名,例如 x is number 或 x is NonNullable<T>。
型別謂詞具有“當且僅當”的語義。如果一個函式返回 x is T,則意味著:
- 如果函式返回
true,則x具有T型別。 - 如果函式返回
false,則x不具有T型別。
如果你期望推斷出型別謂詞但並未如願,那麼你可能觸犯了第二條規則。這在“真值”(truthiness)檢查中經常出現。
tsxfunction getClassroomAverage(students: string[], allScores: Map<string, number>) {const studentScores = students.map(student => allScores.get(student)).filter(score => !!score);return studentScores.reduce((a, b) => a + b) / studentScores.length;// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~// error: Object is possibly 'undefined'.}
TypeScript 沒有為 score => !!score 推斷出型別謂詞,這是正確的:如果返回 true,則 score 是一個 number。但如果返回 false,則 score 可能是 undefined 或 number(具體來說是 0)。這是一個真正的 Bug:如果任何學生考試得分為零,那麼過濾掉他們的分數會使平均分偏高。低於平均水平的人會減少,更多的人會感到難過!
與第一個示例一樣,最好顯式過濾掉 undefined 值。
tsxfunction getClassroomAverage(students: string[], allScores: Map<string, number>) {const studentScores = students.map(student => allScores.get(student)).filter(score => score !== undefined);return studentScores.reduce((a, b) => a + b) / studentScores.length; // ok!}
真值檢查確實會為物件型別推斷出型別謂詞,因為不存在歧義。請記住,函式必須返回 boolean 才有資格被推斷出型別謂詞:x => !!x 可能會推斷出型別謂詞,但 x => x 絕對不會。
顯式型別謂詞的使用方式保持不變。TypeScript 不會檢查它是否會推斷出相同的型別謂詞。顯式型別謂詞(“is”)並不比型別斷言(“as”)更安全。
如果 TypeScript 現在推斷出的型別比你想要的更精確,這一特性可能會破壞現有程式碼。例如:
tsx// Previously, nums: (number | null)[]// Now, nums: number[]const nums = [1, 2, 3, null, 5].filter(x => x !== null);nums.push(null); // ok in TS 5.4, error in TS 5.5
解決方法是使用顯式型別註解告訴 TypeScript 你想要的型別。
tsxconst nums: (number | null)[] = [1, 2, 3, null, 5].filter(x => x !== null);nums.push(null); // ok in all versions
更多資訊,請參閱實現此功能的 Pull Request 以及 Dan 關於實現此功能的博文。
針對常量索引訪問的控制流細化
當 obj 和 key 都是常量時,TypeScript 現在能夠細化 obj[key] 形式的表示式型別。
tsfunction f1(obj: Record<string, unknown>, key: string) {if (typeof obj[key] === "string") {// Now okay, previously was errorobj[key].toUpperCase();}}
在上述程式碼中,obj 和 key 都未被修改,因此 TypeScript 可以在 typeof 檢查後將 obj[key] 的型別細化為 string。更多資訊,請參閱此處實現的 Pull Request。
JSDoc @import 標籤
目前,如果只想在 JavaScript 檔案中匯入某些內容進行型別檢查,操作比較麻煩。如果 SomeType 在執行時不存在,JavaScript 開發者無法簡單地匯入它。
js// ./some-module.d.tsexport interface SomeType {// ...}// ./index.jsimport { SomeType } from "./some-module"; // ❌ runtime error!/*** @param {SomeType} myValue*/function doSomething(myValue) {// ...}
SomeType 在執行時不存在,因此匯入會失敗。開發者可以使用名稱空間匯入來替代。
jsimport * as someModule from "./some-module";/*** @param {someModule.SomeType} myValue*/function doSomething(myValue) {// ...}
但 ./some-module 仍然會在執行時被匯入——這可能不是我們想要的。
為了避免這種情況,開發者通常不得不在 JSDoc 註釋中使用 import(...) 型別。
js/*** @param {import("./some-module").SomeType} myValue*/function doSomething(myValue) {// ...}
如果想在多個地方重用同一個型別,可以使用 typedef 來避免重複匯入。
js/*** @typedef {import("./some-module").SomeType} SomeType*//*** @param {SomeType} myValue*/function doSomething(myValue) {// ...}
這有助於在區域性使用 SomeType,但對於大量匯入來說顯得重複且冗長。
這就是為什麼 TypeScript 現在支援一種新的 @import 註釋標籤,它具有與 ECMAScript 匯入相同的語法。
js/** @import { SomeType } from "some-module" *//*** @param {SomeType} myValue*/function doSomething(myValue) {// ...}
這裡使用了命名匯入。我們也可以將其編寫為名稱空間匯入。
js/** @import * as someModule from "some-module" *//*** @param {someModule.SomeType} myValue*/function doSomething(myValue) {// ...}
因為這些只是 JSDoc 註釋,它們完全不會影響執行時行為。
我們要向貢獻了此更改的 Oleksandr Tarasiuk 表示衷心感謝!
正則表示式語法檢查
在此之前,TypeScript 通常會跳過程式碼中的大多數正則表示式。這是因為正則表示式在技術上具有可擴充套件的語法,且 TypeScript 從未嘗試將正則表示式編譯為舊版本的 JavaScript。然而,這意味著正則表示式中許多常見問題無法被發現,最終要麼導致執行時錯誤,要麼靜默失敗。
但現在 TypeScript 會對正則表示式進行基本的語法檢查!
tslet myRegex = /@robot(\s+(please|immediately)))? do some task/;// ~// error!// Unexpected ')'. Did you mean to escape it with backslash?
這是一個簡單的例子,但這種檢查可以捕獲許多常見的錯誤。事實上,TypeScript 的檢查甚至超越了語法檢查。例如,TypeScript 現在可以捕獲關於不存在的反向引用的問題。
tslet myRegex = /@typedef \{import\((.+)\)\.([a-zA-Z_]+)\} \3/u;// ~// error!// This backreference refers to a group that does not exist.// There are only 2 capturing groups in this regular expression.
命名捕獲組的情況也是如此。
tslet myRegex = /@typedef \{import\((?<importPath>.+)\)\.(?<importedEntity>[a-zA-Z_]+)\} \k<namedImport>/;// ~~~~~~~~~~~// error!// There is no capturing group named 'namedImport' in this regular expression.
TypeScript 的檢查現在還能識別何時使用了比當前目標 ECMAScript 版本更新的 RegExp 特性。例如,如果我們像上面那樣在 ES5 目標中使用命名捕獲組,將會收到錯誤提示。
tslet myRegex = /@typedef \{import\((?<importPath>.+)\)\.(?<importedEntity>[a-zA-Z_]+)\} \k<importedEntity>/;// ~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~// error!// Named capturing groups are only available when targeting 'ES2018' or later.
某些正則表示式標誌也是如此。
請注意,TypeScript 對正則表示式的支援僅限於正則表示式字面量。如果嘗試使用字串字面量呼叫 new RegExp,TypeScript 不會檢查提供的字串。
我們要感謝 GitHub 使用者 graphemecluster,他與我們進行了多次迭代,最終將此功能引入 TypeScript。
支援新的 ECMAScript Set 方法
TypeScript 5.5 聲明瞭 ECMAScript Set 型別的新提案方法。
其中一些方法,如 union、intersection、difference 和 symmetricDifference,接收另一個 Set 並返回一個新的 Set 作為結果。其他方法,如 isSubsetOf、isSupersetOf 和 isDisjointFrom,接收另一個 Set 並返回一個 boolean。這些方法均不會修改原始 Set。
這是一個關於如何使用這些方法及其行為的簡單示例:
tslet fruits = new Set(["apples", "bananas", "pears", "oranges"]);let applesAndBananas = new Set(["apples", "bananas"]);let applesAndOranges = new Set(["apples", "oranges"]);let oranges = new Set(["oranges"]);let emptySet = new Set();////// union////// Set(4) {'apples', 'bananas', 'pears', 'oranges'}console.log(fruits.union(oranges));// Set(3) {'apples', 'bananas', 'oranges'}console.log(applesAndBananas.union(oranges));////// intersection////// Set(2) {'apples', 'bananas'}console.log(fruits.intersection(applesAndBananas));// Set(0) {}console.log(applesAndBananas.intersection(oranges));// Set(1) {'apples'}console.log(applesAndBananas.intersection(applesAndOranges));////// difference////// Set(3) {'apples', 'bananas', 'pears'}console.log(fruits.difference(oranges));// Set(2) {'pears', 'oranges'}console.log(fruits.difference(applesAndBananas));// Set(1) {'bananas'}console.log(applesAndBananas.difference(applesAndOranges));////// symmetricDifference////// Set(2) {'bananas', 'oranges'}console.log(applesAndBananas.symmetricDifference(applesAndOranges)); // no apples////// isDisjointFrom////// trueconsole.log(applesAndBananas.isDisjointFrom(oranges));// falseconsole.log(applesAndBananas.isDisjointFrom(applesAndOranges));// trueconsole.log(fruits.isDisjointFrom(emptySet));// trueconsole.log(emptySet.isDisjointFrom(emptySet));////// isSubsetOf////// trueconsole.log(applesAndBananas.isSubsetOf(fruits));// falseconsole.log(fruits.isSubsetOf(applesAndBananas));// falseconsole.log(applesAndBananas.isSubsetOf(oranges));// trueconsole.log(fruits.isSubsetOf(fruits));// trueconsole.log(emptySet.isSubsetOf(fruits));////// isSupersetOf////// trueconsole.log(fruits.isSupersetOf(applesAndBananas));// falseconsole.log(applesAndBananas.isSupersetOf(fruits));// falseconsole.log(applesAndBananas.isSupersetOf(oranges));// trueconsole.log(fruits.isSupersetOf(fruits));// falseconsole.log(emptySet.isSupersetOf(fruits));
我們要感謝 Kevin Gibbons,他不僅共同推動了 ECMAScript 中的這一特性,還提供了 TypeScript 中 Set、ReadonlySet 和 ReadonlySetLike 的宣告!
獨立宣告 (Isolated Declarations)
本節由 Rob Palmer 共同編寫,他支援了獨立宣告的設計。
宣告檔案(即 .d.ts 檔案)向 TypeScript 描述了現有庫和模組的形狀。這種輕量級的描述包含了庫的型別簽名,排除了函式體等實現細節。釋出這些檔案是為了讓 TypeScript 在不需要分析庫本身的情況下就能高效地檢查你對庫的使用。雖然可以手工編寫宣告檔案,但如果你在編寫帶型別的程式碼,讓 TypeScript 使用 --declaration 從原始檔自動生成它們會更安全、更簡單。
TypeScript 編譯器及其 API 一直負責生成宣告檔案;然而,有些用例是你可能想使用其他工具,或者傳統的構建過程無法擴充套件的情況。
用例:更快的宣告生成工具
想象一下,如果你想建立一個更快的工具來生成宣告檔案,也許是作為釋出服務或新打包器的一部分。雖然生態系統中有很多將 TypeScript 轉換為 JavaScript 的高速工具,但在將 TypeScript 轉換為宣告檔案方面卻並非如此。原因在於 TypeScript 的型別推斷允許我們在不顯式宣告型別的情況下編寫程式碼,這意味著宣告生成可能非常複雜。
讓我們考慮一個簡單的例子,一個將兩個匯入變數相加的函式。
ts// util.tsexport let one = "1";export let two = "2";// add.tsimport { one, two } from "./util";export function add() { return one + two; }
即使我們唯一想做的事情就是生成 add.d.ts,TypeScript 也需要深入研究另一個匯入檔案 (util.ts),推斷出 one 和 two 的型別是字串,然後計算出對兩個字串使用 + 運算子會導致 string 返回型別。
ts// add.d.tsexport declare function add(): string;
雖然這種推斷對於開發者體驗很重要,但這意味著想要生成宣告檔案的工具將需要複製型別檢查器的部分功能,包括推斷能力以及解析模組說明符以跟蹤匯入的能力。
用例:並行宣告生成與並行檢查
想象一下,如果你有一個包含多個專案的倉庫,且有一臺多核 CPU,希望它能幫助你更快地檢查程式碼。如果我們能透過在不同的核心上執行每個專案來同時檢查所有這些專案,那該多好?
遺憾的是,我們沒有自由並行地完成所有工作。原因在於我們必須按依賴順序構建這些專案,因為每個專案都在根據其依賴項的宣告檔案進行檢查。所以我們必須先構建依賴項以生成宣告檔案。TypeScript 的專案引用特性也以同樣的方式工作,按“拓撲”依賴順序構建專案集。
例如,如果我們有兩個名為 backend 和 frontend 的專案,它們都依賴於一個名為 core 的專案,那麼在 core 構建完成且其宣告檔案生成之前,TypeScript 無法開始檢查 frontend 或 backend。

在上述圖中,你可以看到存在一個瓶頸。雖然我們可以並行構建 frontend 和 backend,但在它們開始之前,必須先等待 core 完成構建。
我們該如何改進這一點?嗯,如果一個快速工具可以並行為 core 生成所有這些宣告檔案,TypeScript 就可以緊接著並行對 core、frontend 和 backend 進行型別檢查。
解決方案:顯式型別!
這兩個用例的共同要求是我們需要一個跨檔案的型別檢查器來生成宣告檔案,這對工具社群來說要求很高。
作為一個更復雜的例子,如果我們想為以下程式碼生成宣告檔案……
tsimport { add } from "./add";const x = add();export function foo() {return x;}
……我們需要為 foo 生成簽名。這需要檢視 foo 的實現。foo 只是返回 x,所以獲取 x 的型別需要檢視 add 的實現。但這可能需要檢視 add 的依賴項的實現,依此類推。我們在這裡看到的是,生成宣告檔案需要大量的邏輯來計算出那些甚至可能不在當前檔案中的不同地方的型別。
儘管如此,對於追求快速迭代時間和完全並行構建的開發者來說,還有另一種思考這個問題的方式。宣告檔案只需要模組公共 API 的型別——換句話說,匯出的內容的型別。如果開發者願意爭議性地顯式寫出他們匯出的內容的型別,工具就可以在不需要檢視模組實現的情況下生成宣告檔案,並且不需要重新實現完整的型別檢查器。
這就是新的 --isolatedDeclarations 選項的用武之地。--isolatedDeclarations 會在無法在沒有型別檢查器的情況下可靠地轉換模組時報告錯誤。更簡單地說,如果你的檔案匯出內容沒有足夠的註解,它會使 TypeScript 報告錯誤。
這意味著在上面的例子中,我們會看到類似以下的錯誤。
tsexport function foo() {// ~~~// error! Function must have an explicit// return type annotation with --isolatedDeclarations.return x;}
為什麼錯誤是可取的?
因為它意味著 TypeScript 可以:
- 預先告訴我們其他工具在生成宣告檔案時是否會遇到問題。
- 提供快速修復來幫助新增這些缺失的註解。
不過,此模式並不需要到處都有註解。對於區域性變數,可以忽略,因為它們不影響公共 API。例如,以下程式碼不會產生錯誤。
tsimport { add } from "./add";const x = add("1", "2"); // no error on 'x', it's not exported.export function foo(): string {return x;}
也有某些型別的計算是“瑣碎”的。
ts// No error on 'x'.// It's trivial to calculate the type is 'number'export let x = 10;// No error on 'y'.// We can get the type from the return expression.export function y() {return 20;}// No error on 'z'.// The type assertion makes it clear what the type is.export function z() {return Math.max(x, y()) as number;}
使用 isolatedDeclarations
isolatedDeclarations 要求同時設定 declaration 或 composite 標誌。
請注意,isolatedDeclarations 不會改變 TypeScript 執行生成的行為——只會改變報告錯誤的方式。重要的是,類似於 isolatedModules,在 TypeScript 中啟用該特性並不會立即帶來這裡討論的潛在好處。因此,請保持耐心並期待這一領域的未來發展。考慮到工具作者的需求,我們也應認識到,如今並不是所有 TypeScript 的宣告生成都能被想要利用它的其他工具輕易複製。這是我們正在積極改進的地方。
此外,獨立宣告仍然是一個新特性,我們正在積極改善其體驗。在 isolatedDeclarations 下,類和物件字面量中的某些場景(如計算屬性宣告)尚不支援。請密切關注這一領域,並隨時提供反饋。
我們還認為值得指出的是,isolatedDeclarations 應該根據具體情況採用。使用 isolatedDeclarations 會失去一些開發者的人體工程學體驗,因此如果你的環境沒有利用前面提到的兩個場景,它可能不是正確的選擇。對於其他人來說,關於 isolatedDeclarations 的工作已經發現了許多最佳化和解鎖不同並行構建策略的機會。在此期間,如果你願意做出權衡,我們相信 isolatedDeclarations 可以成為加速構建過程的強大工具,因為外部工具正變得越來越廣泛可用。
有關更多資訊,請閱讀 TypeScript 問題追蹤器上的獨立宣告:特性狀態討論。
致謝
關於 isolatedDeclarations 的工作是 TypeScript 團隊與 Bloomberg 和 Google 的基礎設施與工具團隊之間長期的協作成果。諸如來自 Google 的 Hana Joo(實現了獨立宣告錯誤的快速修復,稍後會詳細介紹),以及 Ashley Claymore、Jan Kühle、Lisa Velden、Rob Palmer 和 Thomas Chetwin 等人在數月間參與了討論、規範和實現。但我們認為特別值得一提的是來自 Bloomberg 的 Titian Cernicova-Dragomir 所提供的巨大貢獻。Titian 在推動 isolatedDeclarations 的實現方面發揮了重要作用,並且在此之前多年一直是 TypeScript 專案的貢獻者。
雖然該特性涉及許多更改,但你可以在此處檢視獨立宣告的核心工作。
配置檔案中的 ${configDir} 模板變數
在許多程式碼庫中,重用一個作為其他配置檔案“基礎”的共享 tsconfig.json 檔案是很常見的。這是透過在 tsconfig.json 檔案中使用 extends 欄位實現的。
json{"extends": "../../tsconfig.base.json","compilerOptions": {"outDir": "./dist"}}
這存在一個問題:tsconfig.json 檔案中的所有路徑都是相對於檔案本身位置的。這意味著如果你有一個被多個專案使用的共享 tsconfig.base.json 檔案,相對路徑在衍生專案中往往不起作用。例如,想象以下 tsconfig.base.json:
json{"compilerOptions": {"typeRoots": ["./node_modules/@types","./custom-types"],"outDir": "dist"}}
如果作者的意圖是每一個繼承此檔案的 tsconfig.json 都應該:
- 輸出到相對於衍生
tsconfig.json的dist目錄,以及 - 擁有一個相對於衍生
tsconfig.json的custom-types目錄,
那麼這將無法工作。typeRoots 路徑將相對於共享 tsconfig.base.json 檔案的位置,而不是繼承它的專案。每個繼承此共享檔案的專案都需要宣告自己的 outDir 和 typeRoots,且內容相同。這可能會令人沮喪且難以在專案之間保持同步,雖然上面的示例使用的是 typeRoots,但這對於 paths 和其他選項來說也是一個常見問題。
為了解決這個問題,TypeScript 5.5 引入了一個新的模板變數 ${configDir}。當 ${configDir} 被寫入 tsconfig.json 或 jsconfig.json 檔案的某些路徑欄位時,該變數會被替換為給定編譯中配置檔案的所在目錄。這意味著上述 tsconfig.base.json 可以改寫為:
json{"compilerOptions": {"typeRoots": ["${configDir}/node_modules/@types","${configDir}/custom-types"],"outDir": "${configDir}/dist"}}
現在,當專案繼承此檔案時,路徑將相對於衍生 tsconfig.json,而不是共享的 tsconfig.base.json 檔案。這使得在專案之間共享配置檔案變得更加容易,並確保配置檔案更具可移植性。
如果你打算使 tsconfig.json 檔案可擴充套件,請考慮是否應該使用 ${configDir} 來代替 ./。
更多資訊,請參閱提案問題和實現此功能的 Pull Request。
在生成宣告檔案時諮詢 package.json 依賴項
以前,TypeScript 經常會丟擲如下錯誤訊息:
The inferred type of "X" cannot be named without a reference to "Y". This is likely not portable. A type annotation is necessary.
這通常是因為 TypeScript 的宣告檔案生成功能發現自己處於程式中從未顯式匯入的檔案內容中。如果路徑最終是相對的,生成對這樣檔案的匯入可能是有風險的。不過,對於 package.json 的 dependencies(或 peerDependencies 和 optionalDependencies)中具有顯式依賴項的程式碼庫,在某些解析模式下生成此類匯入應該是安全的。因此,在 TypeScript 5.5 中,在這種情況下我們會更加寬鬆,該錯誤的許多出現頻率應該會消失。
參閱此 Pull Request 以瞭解有關此更改的更多詳細資訊。
編輯器和監視模式可靠性改進
TypeScript 添加了一些新功能或修復了現有邏輯,使 --watch 模式和 TypeScript 的編輯器整合感覺更可靠。希望這能轉化為更少的 TSServer/編輯器重啟。
正確重新整理配置檔案中的編輯器錯誤
TypeScript 可以為 tsconfig.json 檔案生成錯誤;然而,這些錯誤實際上是在載入專案時生成的,編輯器通常不會直接請求 tsconfig.json 檔案的這些錯誤。雖然這聽起來像是一個技術細節,但這意味著當 tsconfig.json 中釋出的所有錯誤都得到修復時,TypeScript 不會發布一組新的空錯誤,使用者會被遺留的過時錯誤困擾,除非重新載入編輯器。
TypeScript 5.5 現在會有意釋出一個事件來清除這些錯誤。在此處檢視更多。
對刪除後立即寫入的更好處理
一些工具會選擇刪除檔案然後從頭開始建立新檔案,而不是覆蓋它們。例如在執行 npm ci 時就是這種情況。
雖然這對於這些工具來說是高效的,但對於 TypeScript 的編輯器場景來說可能會有問題,因為刪除一個被監視的檔案可能會銷燬它及其所有傳遞依賴項。快速連續地刪除和建立檔案可能導致 TypeScript 拆除整個專案然後從頭開始重建。
TypeScript 5.5 現在採用了一種更細緻的方法,即保留被刪除專案的部分內容,直到它接收到新的建立事件。這應該能使 npm ci 等操作在 TypeScript 下工作得更好。檢視此處關於該方法的更多資訊。
在失敗的解析中跟蹤符號連結
當 TypeScript 無法解析模組時,它仍然需要監視任何失敗的查詢路徑,以防模組稍後被新增。以前對於符號連結目錄沒有這樣做,這可能導致在類 monorepo 場景中出現可靠性問題,即在一個專案中發生的構建在另一個專案中未被發現。這個問題應該在 TypeScript 5.5 中得到修復,這意味著你不需要那麼頻繁地重啟編輯器。
專案引用有助於自動匯入
自動匯入不再需要專案引用設定中的依賴專案至少有一個顯式匯入。相反,自動匯入補全應該能夠在你 tsconfig.json 的 references 欄位中列出的任何內容中直接起作用。
效能和體積最佳化
語言服務和公共 API 中的單態化物件
在 TypeScript 5.0 中,我們確保了我們的 Node 和 Symbol 物件擁有一組具有一致初始化順序的屬性。這樣做有助於減少不同操作中的多型性,從而允許執行時更快地獲取屬性。
透過此項更改,我們在編譯器中見證了令人印象深刻的速度提升;然而,這些更改大多是在我們資料結構的內部分配器上執行的。語言服務以及 TypeScript 的公共 API 對某些物件使用了一組不同的分配器。這允許 TypeScript 編譯器更加精簡,因為僅用於語言服務的資料永遠不會在編譯器中使用。
在 TypeScript 5.5 中,語言服務和公共 API 也完成了相同的單態化工作。這意味著你的編輯器體驗以及任何使用 TypeScript API 的構建工具都將獲得顯著的速度提升。事實上,在我們的基準測試中,我們看到在使用公共 TypeScript API 分配器時構建時間提升了 5-8%,語言服務操作速度提升了 10-20%。雖然這意味著記憶體有所增加,但我們認為這種權衡是值得的,並希望找到減少該記憶體開銷的方法。現在的感覺應該會更加靈敏。
更多資訊,參閱此處的更改。
單態化控制流節點
在 TypeScript 5.5 中,控制流圖的節點已經過單態化,因此它們始終保持一致的形狀。透過這樣做,檢查時間通常會減少約 1%。
控制流圖最佳化
在許多情況下,控制流分析會遍歷不提供任何新資訊的節點。我們觀察到,如果某些節點的前驅(或“支配者”)中不存在早期終止或影響,則這些節點始終可以跳過。因此,TypeScript 現在構建其控制流圖時,透過連結到提供控制流分析所需資訊的更早節點來利用這一點。這產生了一個更平坦的控制流圖,遍歷起來效率更高。此最佳化產生了適度的收益,在某些程式碼庫中構建時間減少了高達 2%。
你可以在此處閱讀更多資訊。
跳過 transpileModule 和 transpileDeclaration 中的檢查
TypeScript 的 transpileModule API 可用於將單個 TypeScript 檔案的內容編譯為 JavaScript。類似地,transpileDeclaration API(見下文)可用於為單個 TypeScript 檔案生成宣告檔案。這些 API 的一個問題是,TypeScript 在內部會在輸出之前對檔案的全部內容執行完整的型別檢查透過。這對於收集後續生成階段所需的某些資訊是必需的。
在 TypeScript 5.5 中,我們找到了一種避免進行完整檢查的方法,僅在必要時懶載入地收集這些資訊。transpileModule 和 transpileDeclaration 預設啟用了此功能。因此,與這些 API 整合的工具(如帶有 transpileOnly 的 ts-loader 和 ts-jest)應該會看到明顯的速度提升。在我們的測試中,使用 transpileModule 時,我們通常會看到構建時間縮短約 2 倍。
TypeScript 包大小縮減
進一步利用我們在 5.0 中向模組的過渡,我們透過讓 tsserver.js 和 typingsInstaller.js 從公共 API 庫匯入,而不是讓它們各自生成獨立捆綁包,顯著減小了 TypeScript 的整體包大小。
這使 TypeScript 在磁碟上的大小從 30.2 MB 減小到 20.4 MB,並將其打包大小從 5.5 MB 減小到 3.7 MB!
宣告生成中的節點重用
作為啟用 isolatedDeclarations 工作的一部分,我們大幅提升了 TypeScript 在生成宣告檔案時直接複製你輸入原始碼的頻率。
例如,假設你編寫了:
tsexport const strBool: string | boolean = "hello";export const boolStr: boolean | string = "world";
請注意,聯合型別是等價的,但聯合的順序不同。在生成宣告檔案時,TypeScript 有兩種等價的輸出可能性。
第一種是對每個型別使用一致的規範表示:
tsexport const strBool: string | boolean;export const boolStr: string | boolean;
第二種是按原樣重用型別註解:
tsexport const strBool: string | boolean;export const boolStr: boolean | string;
由於以下幾個原因,第二種方法通常更可取:
- 許多等價表示仍然編碼了一定程度的意圖,在宣告檔案中最好保留這些意圖。
- 生成型別的新表示可能有些昂貴,所以最好避免。
- 使用者編寫的型別通常比生成的型別表示更短。
在 5.5 中,我們極大地改善了 TypeScript 可以正確識別並安全、正確地按輸入檔案中編寫原樣列印型別的地方。其中許多是隱形的效能改進——以前 TypeScript 會生成新的語法節點集並將它們序列化為字串。現在,TypeScript 可以直接在原始語法節點上操作,這更廉價且更快速。
快取來自判別聯合的上下文型別
當 TypeScript 為物件字面量等表示式請求上下文型別時,它經常會遇到聯合型別。在這些情況下,TypeScript 會嘗試根據具有已知值(即判別屬性)的屬性來過濾聯合成員。這項工作可能相當昂貴,特別是如果你最終得到一個由許多屬性組成的物件時。在 TypeScript 5.5 中,大部分計算都被快取了一次,因此 TypeScript 不需要為物件字面量中的每個屬性重新計算它。執行此最佳化為編譯 TypeScript 編譯器本身節省了 250ms。
更輕鬆地從 ECMAScript 模組使用 API
以前,如果你在 Node.js 中編寫 ECMAScript 模組,則無法從 typescript 包中使用命名匯入。
tsimport { createSourceFile } from "typescript"; // ❌ errorimport * as ts from "typescript";ts.createSourceFile // ❌ undefined???ts.default.createSourceFile // ✅ works - but ugh!
這是因為 cjs-module-lexer 沒有識別 TypeScript 生成的 CommonJS 程式碼模式。這個問題已得到修復,使用者現在可以在 Node.js 的 ECMAScript 模組中使用來自 TypeScript npm 包的命名匯入。
tsimport { createSourceFile } from "typescript"; // ✅ works now!import * as ts from "typescript";ts.createSourceFile // ✅ works now!
有關更多資訊,參閱此處的更改。
transpileDeclaration API
TypeScript 的 API 公開了一個名為 transpileModule 的函式。它旨在讓你輕鬆編譯單個 TypeScript 程式碼檔案。因為它無法訪問整個程式,所以需要注意的是,如果程式碼違反了 isolatedModules 選項下的任何錯誤,它可能不會產生正確的輸出。
在 TypeScript 5.5 中,我們添加了一個類似的新 API,稱為 transpileDeclaration。此 API 與 transpileModule 類似,但它是專門設計用於基於某些輸入源文字生成單個宣告檔案的。就像 transpileModule 一樣,它無法訪問完整的程式,並且適用類似的警告:只有當輸入程式碼在新的 isolatedDeclarations 選項下沒有錯誤時,它才會生成準確的宣告檔案。
如果需要,此函式可用於在 isolatedDeclarations 模式下跨所有檔案並行化宣告生成。
有關更多資訊,參閱此處的實現。
顯著的行為更改
本節重點介紹了一組值得注意的更改,在進行任何升級時都應予以確認和理解。有時它會突出顯示棄用、移除和新的限制。它也可能包含功能上有所改進但透過引入新錯誤而可能影響現有構建的錯誤修復。
停用在 TypeScript 5.0 中棄用的特性
TypeScript 5.0 棄用了以下選項和行為:
charsettarget: ES3importsNotUsedAsValuesnoImplicitUseStrictnoStrictGenericCheckskeyofStringsOnlysuppressExcessPropertyErrorssuppressImplicitAnyIndexErrorsoutpreserveValueImports- 專案引用中的
prepend - 隱式特定於 OS 的
newLine
為了繼續使用上述已棄用的選項,使用 TypeScript 5.0 及更新版本的開發者必須指定一個名為 ignoreDeprecations 且值為 "5.0" 的新選項。
在 TypeScript 5.5 中,這些選項不再有任何效果。為了幫助平滑升級,你仍然可以在 tsconfig 中指定它們,但在 TypeScript 6.0 中,指定它們將導致錯誤。另請參閱概述我們棄用策略的標誌棄用計劃。
有關這些棄用計劃的更多資訊可在 GitHub 上獲得,其中包含了如何最好地適應你的程式碼庫的建議。
lib.d.ts 變更
為 DOM 生成的型別可能會對你的程式碼庫型別檢查產生影響。有關更多資訊,參閱 TypeScript 5.5 的 DOM 更新。
更嚴格的裝飾器解析
自 TypeScript 最初引入對裝飾器的支援以來,該提案的指定語法已得到加強。TypeScript 現在對其允許的形式更加嚴格。雖然很少見,但現有的裝飾器可能需要加括號以避免錯誤。
tsclass DecoratorProvider {decorate(...args: any[]) { }}class D extends DecoratorProvider {m() {class C {@super.decorate // ❌ errormethod1() { }@(super.decorate) // ✅ okaymethod2() { }}}}
參閱此處有關更改的更多資訊。
undefined 不再是可定義的型別名稱
TypeScript 一直不允許與內建型別衝突的類型別名名稱。
ts// Illegaltype null = any;// Illegaltype number = any;// Illegaltype object = any;// Illegaltype any = any;
由於一個錯誤,此邏輯並未應用於內建型別 undefined。在 5.5 中,這現在被正確標識為錯誤。
ts// Now also illegaltype undefined = any;
名為 undefined 的類型別名的裸引用實際上從未真正工作過。你可以定義它們,但不能將它們用作非限定型別名稱。
tsexport type undefined = string;export const m: undefined = "";// ^// Errors in 5.4 and earlier - the local definition of 'undefined' was not even consulted.
更多資訊,參閱此處的更改。
簡化參考指令宣告生成
在生成宣告檔案時,TypeScript 會在認為需要時合成參考指令。例如,所有 Node.js 模組都是環境宣告的,因此不能僅由模組解析載入。像這樣的檔案:
tsximport path from "path";export const myPath = path.parse(__filename);
將生成如下宣告檔案:
tsx/// <reference types="node" />import path from "path";export declare const myPath: path.ParsedPath;
即使參考指令從未出現在原始原始檔中。
同樣,TypeScript 也刪除了它認為不需要作為輸出一部分的參考指令。例如,假設我們有一個指向 jest 的參考指令;然而,假設生成宣告檔案並不需要該參考指令。TypeScript 會直接刪除它。因此在以下示例中:
tsx/// <reference types="jest" />import path from "path";export const myPath = path.parse(__filename);
TypeScript 仍然會生成:
tsx/// <reference types="node" />import path from "path";export declare const myPath: path.ParsedPath;
在進行 isolatedDeclarations 工作時,我們意識到這種邏輯對於任何試圖在沒有型別檢查或僅使用單個檔案上下文的情況下實現宣告生成器的人來說是不可持續的。從使用者的角度來看,這種行為也很難理解;除非你確切瞭解型別檢查期間發生了什麼,否則參考指令是否出現在生成的檔案中似乎不一致且難以預測。為了防止在啟用 isolatedDeclarations 時宣告生成有所不同,我們知道我們的生成需要改變。
透過實驗,我們發現幾乎所有 TypeScript 合成參考指令的情況都只是為了引入 node 或 react。在這些情況下,預期是下游使用者已經透過 tsconfig.json "types" 或庫匯入引用了這些型別,因此不再合成這些參考指令不太可能破壞任何人的程式碼。值得注意的是,對於 lib.d.ts 來說已經如此;當模組匯出 WeakMap 時,TypeScript 不會合成對 lib="es2015" 的引用,而是假設下游使用者已將其包含在其環境中。
對於由庫作者編寫(而非合成)的參考指令,進一步實驗表明幾乎所有都被移除了,從未出現在輸出中。大多數被保留的參考指令都是損壞的,且可能無意被保留。
鑑於這些結果,我們決定在 TypeScript 5.5 中極大簡化宣告生成中的參考指令。更一致的策略將幫助庫作者和消費者更好地控制他們的宣告檔案。
參考指令不再被合成。除非標記有新的 preserve="true" 屬性,否則使用者編寫的參考指令不再被保留。具體來說,像這樣的輸入檔案:
tsx/// <reference types="some-lib" preserve="true" />/// <reference types="jest" />import path from "path";export const myPath = path.parse(__filename);
將生成:
tsx/// <reference types="some-lib" preserve="true" />import path from "path";export declare const myPath: path.ParsedPath;
新增 preserve="true" 向後相容舊版本的 TypeScript,因為未知屬性會被忽略。
這一更改也提高了效能;在我們的基準測試中,啟用了宣告生成的專案的生成階段效率提升了 1-4%。