TypeScript 4.7

Node.js 中的 ECMAScript 模組支援

在過去的幾年裡,Node.js 一直致力於支援 ECMAScript 模組 (ESM)。這是一個非常困難的功能,因為 Node.js 生態系統是建立在一種稱為 CommonJS (CJS) 的不同模組系統上的。兩者之間的互操作帶來了巨大的挑戰,需要兼顧許多新特性;然而,Node.js 對 ESM 的支援主要是在 Node.js 12 及更高版本中實現的。在 TypeScript 4.5 左右,我們推出了僅限 nightly 構建的 Node.js ESM 支援,以從使用者那裡獲取反饋,並讓庫作者為更廣泛的支援做好準備。

TypeScript 4.7 透過兩個新的 module 設定增加了此功能:node16nodenext

jsonc
{
"compilerOptions": {
"module": "node16",
}
}

這些新模式帶來了一些高階功能,我們將在下面進行探討。

package.json 中的 type 和新副檔名

Node.js 支援 package.json 中的一個新設定,稱為 type"type" 可以設定為 "module""commonjs"

jsonc
{
"name": "my-package",
"type": "module",
"//": "...",
"dependencies": {
}
}

此設定控制 .js.d.ts 檔案是被解釋為 ES 模組還是 CommonJS 模組,如果未設定,則預設為 CommonJS。當檔案被視為 ES 模組時,與 CommonJS 相比,會有一些不同的規則生效:

  • 可以使用 import/export 語句。
  • 可以使用頂層 await
  • 相對匯入路徑需要完整副檔名(我們必須寫 import "./foo.js" 而不是 import "./foo")。
  • 匯入的解析方式可能與 node_modules 中的依賴項不同。
  • 某些類似全域性的值(如 requiremodule)不能直接使用。
  • CommonJS 模組會根據某些特殊規則進行匯入。

我們稍後會回到其中一些點。

為了覆蓋 TypeScript 在此係統中的工作方式,.ts.tsx 檔案現在以相同的方式工作。當 TypeScript 找到一個 .ts.tsx.js.jsx 檔案時,它會向上遍歷查詢 package.json,以檢視該檔案是否為 ES 模組,並用它來確定:

  • 如何查詢該檔案匯入的其他模組
  • 以及如果產生輸出,如何轉換該檔案

.ts 檔案被編譯為 ES 模組時,ECMAScript import/export 語句在 .js 輸出中會被保留;當它被編譯為 CommonJS 模組時,它將產生與您今天在 --module commonjs 下獲得的相同輸出。

這也意味著路徑在作為 ES 模組的 .ts 檔案和作為 CJS 模組的 .ts 檔案之間解析方式不同。例如,假設您今天有以下程式碼:

ts
// ./foo.ts
export function helper() {
// ...
}
// ./bar.ts
import { helper } from "./foo"; // only works in CJS
helper();

此程式碼在 CommonJS 模組中有效,但在 ES 模組中會失敗,因為相對匯入路徑需要使用副檔名。因此,它必須重寫為使用 foo.ts輸出副檔名 —— 所以 bar.ts 將不得不改為從 ./foo.js 匯入。

ts
// ./bar.ts
import { helper } from "./foo.js"; // works in ESM & CJS
helper();

這起初可能會讓人覺得有點麻煩,但 TypeScript 的自動匯入和路徑補全等工具通常會自動為您完成這些操作。

還需要提到的一點是,這同樣適用於 .d.ts 檔案。當 TypeScript 在包中找到 .d.ts 檔案時,它會根據所在的包進行解釋。

新副檔名

package.json 中的 type 欄位很好,因為它允許我們繼續使用 .ts.js 副檔名,這很方便;但是,您偶爾需要編寫一個不同於 type 指定型別的檔案。您可能也更喜歡始終保持明確。

Node.js 支援兩個副檔名來幫助實現這一點:.mjs.cjs.mjs 檔案始終是 ES 模組,.cjs 檔案始終是 CommonJS 模組,並且沒有辦法覆蓋它們。

反過來,TypeScript 支援兩個新的原始檔副檔名:.mts.cts。當 TypeScript 將這些檔案編譯為 JavaScript 檔案時,它會將它們分別編譯為 .mjs.cjs

此外,TypeScript 還支援兩個新的宣告副檔名:.d.mts.d.cts。當 TypeScript 為 .mts.cts 生成宣告檔案時,它們的對應副檔名將是 .d.mts.d.cts

使用這些副檔名完全是可選的,但即使您選擇不將它們作為主要工作流程的一部分,它們通常也很有用。

CommonJS 互操作性

Node.js 允許 ES 模組像匯入具有預設匯出的 ES 模組一樣匯入 CommonJS 模組。

ts
// ./foo.cts
export function helper() {
console.log("hello world!");
}
// ./bar.mts
import foo from "./foo.cjs";
// prints "hello world!"
foo.helper();

在某些情況下,Node.js 還會從 CommonJS 模組中合成具名匯出,這可能會更方便。在這些情況下,ES 模組可以使用“名稱空間風格”匯入(即 import * as foo from "...")或具名匯入(即 import { helper } from "...")。

ts
// ./foo.cts
export function helper() {
console.log("hello world!");
}
// ./bar.mts
import { helper } from "./foo.cjs";
// prints "hello world!"
helper();

TypeScript 並不總是能夠知道這些具名匯出是否會被合成,但 TypeScript 會盡量保持寬鬆,並在從肯定是一個 CommonJS 模組的檔案匯入時使用一些啟發式方法。

關於互操作的一個 TypeScript 特有的注意事項是以下語法:

ts
import foo = require("foo");

在 CommonJS 模組中,這只是簡化為 require() 呼叫;而在 ES 模組中,這會匯入 createRequire 來實現同樣的事情。這會使程式碼在瀏覽器等執行時(不支援 require())上的可移植性變差,但對於互操作性通常很有用。反過來,您可以按照以下方式編寫上述示例:

ts
// ./foo.cts
export function helper() {
console.log("hello world!");
}
// ./bar.mts
import foo = require("./foo.cjs");
foo.helper()

最後,值得注意的是,從 CJS 模組匯入 ESM 檔案的唯一方法是使用動態 import() 呼叫。這可能會帶來挑戰,但這是 Node.js 目前的行為。

您可以在此處閱讀有關 Node.js 中 ESM/CommonJS 互操作性的更多資訊

package.json 的 exports、imports 和自引用

Node.js 支援一個用於定義 package.json 入口點的新欄位,稱為 "exports"。該欄位是定義 package.json"main" 的一種更強大的替代方案,並且可以控制您的包中哪些部分對消費者公開。

這是一個支援 CommonJS 和 ESM 單獨入口點的 package.json 示例:

jsonc
// package.json
{
"name": "my-package",
"type": "module",
"exports": {
".": {
// Entry-point for `import "my-package"` in ESM
"import": "./esm/index.js",
// Entry-point for `require("my-package") in CJS
"require": "./commonjs/index.cjs",
},
},
// CJS fall-back for older versions of Node.js
"main": "./commonjs/index.cjs",
}

此功能有很多內容,您可以在 Node.js 文件中閱讀更多相關資訊。在這裡,我們將重點介紹 TypeScript 是如何支援它的。

在 TypeScript 最初的 Node 支援中,它會查詢 "main" 欄位,然後查詢與該入口對應的宣告檔案。例如,如果 "main" 指向 ./lib/index.js,TypeScript 會查詢名為 ./lib/index.d.ts 的檔案。包作者可以透過指定一個名為 "types" 的單獨欄位(例如 "types": "./types/index.d.ts")來覆蓋此行為。

新的支援透過 import 條件以類似的方式工作。預設情況下,TypeScript 將相同的規則覆蓋到 import 條件上 —— 如果您從 ES 模組執行 import,它將查詢 import 欄位;如果從 CommonJS 模組,它將檢視 require 欄位。如果找到了,它將查詢對應的宣告檔案。如果您需要指向型別宣告的其他位置,可以新增一個 "types" import 條件。

jsonc
// package.json
{
"name": "my-package",
"type": "module",
"exports": {
".": {
// Entry-point for `import "my-package"` in ESM
"import": {
// Where TypeScript will look.
"types": "./types/esm/index.d.ts",
// Where Node.js will look.
"default": "./esm/index.js"
},
// Entry-point for `require("my-package") in CJS
"require": {
// Where TypeScript will look.
"types": "./types/commonjs/index.d.cts",
// Where Node.js will look.
"default": "./commonjs/index.cjs"
},
}
},
// Fall-back for older versions of TypeScript
"types": "./types/index.d.ts",
// CJS fall-back for older versions of Node.js
"main": "./commonjs/index.cjs"
}

"types" 條件在 "exports" 中應始終放在第一位。

需要注意的是,CommonJS 入口點和 ES 模組入口點各自需要自己的宣告檔案,即使它們的內容相同。每個宣告檔案都會根據其副檔名和 package.json"type" 欄位被解釋為 CommonJS 模組或 ES 模組,並且此檢測到的模組型別必須與 Node 檢測到的對應 JavaScript 檔案的模組型別匹配,才能使型別檢查正確。嘗試使用單個 .d.ts 檔案來為 ES 模組入口點和 CommonJS 入口點提供型別定義,將導致 TypeScript 認為這些入口點中只有一個存在,從而導致包的使用者出現編譯器錯誤。

TypeScript 還以類似的方式透過在對應檔案旁邊查詢宣告檔案來支援 package.json"imports" 欄位,並支援 包自引用自身。這些功能的設定通常沒有那麼複雜,但均受支援。

期待您的反饋!

隨著我們繼續開發 TypeScript 4.7,我們預計會看到更多關於此功能的文件和完善。支援這些新特性是一項艱鉅的任務,這就是為什麼我們尋求早期的反饋!請嘗試一下並讓我們知道它在您的情況中如何工作。

有關更多資訊,您可以檢視此處的實現 PR

模組檢測控制

JavaScript 引入模組後的一個問題是現有的“指令碼”程式碼與新模組程式碼之間的歧義。模組中的 JavaScript 程式碼執行方式略有不同,並且具有不同的作用域規則,因此工具必須決定每個檔案的執行方式。例如,Node.js 要求模組入口點必須以 .mjs 編寫,或者在其附近有一個 "type": "module"package.json。TypeScript 只要在檔案中發現任何 importexport 語句,就會將檔案視為模組,否則,它會假設 .ts.js 檔案是作用於全域性作用域的指令碼檔案。

這與 Node.js 的行為不太一致(其中 package.json 可以改變檔案的格式),也不符合 --jsx 設定 react-jsx 的行為(其中任何 JSX 檔案都包含對 JSX 工廠的隱式匯入)。它也不符合現代的期望,即大多數新的 TypeScript 程式碼都是以模組為考量編寫的。

這就是為什麼 TypeScript 4.7 引入了一個名為 moduleDetection 的新選項。moduleDetection 可以取 3 個值:"auto"(預設值)、"legacy"(與 4.6 及更早版本相同的行為)和 "force"

"auto" 模式下,TypeScript 不僅會查詢 importexport 語句,還會檢查:

  • 當執行在 --module nodenext/--module node16 下時,檢查 package.json 中的 "type" 欄位是否設定為 "module";以及
  • 當執行在 --jsx react-jsx 下時,檢查當前檔案是否為 JSX 檔案

在您希望將每個檔案都視為模組的情況下,"force" 設定可確保每個非宣告檔案都被視為模組。無論 modulemoduleResolutionjsx 如何配置,這都是成立的。

同時,"legacy" 選項只是迴歸到舊的行為,即僅尋找 importexport 語句來確定檔案是否為模組。

您可以在 pull request 上閱讀有關此更改的更多資訊

括號元素訪問的控制流分析

TypeScript 4.7 現在會在索引鍵是字面量型別和唯一符號時收窄元素訪問的型別。例如,看以下程式碼:

ts
const key = Symbol();
const numberOrString = Math.random() < 0.5 ? 42 : "hello";
const obj = {
[key]: numberOrString,
};
if (typeof obj[key] === "string") {
let str = obj[key].toUpperCase();
}

以前,TypeScript 不會考慮 obj[key] 上的任何型別守衛,也不知道 obj[key] 實際上是一個 string。相反,它會認為 obj[key] 仍然是 string | number,並且訪問 toUpperCase() 會觸發錯誤。

TypeScript 4.7 現在知道 obj[key] 是一個字串。

這也意味著在 --strictPropertyInitialization 下,TypeScript 可以正確檢查計算屬性是否在建構函式體結束時已初始化。

ts
// 'key' has type 'unique symbol'
const key = Symbol();
class C {
[key]: string;
constructor(str: string) {
// oops, forgot to set 'this[key]'
}
screamString() {
return this[key].toUpperCase();
}
}

在 TypeScript 4.7 下,--strictPropertyInitialization 會報告一個錯誤,告訴我們 [key] 屬性在建構函式結束時沒有被明確賦值。

我們要感謝 Oleksandr Tarasiuk 提供了此更改

物件和方法中改進的函式推斷

TypeScript 4.7 現在可以從物件和陣列中的函式執行更細粒度的推斷。這使得這些函式的型別可以像普通引數一樣以從左到右的方式一致地流動。

ts
declare function f<T>(arg: {
produce: (n: string) => T,
consume: (x: T) => void }
): void;
// Works
f({
produce: () => "hello",
consume: x => x.toLowerCase()
});
// Works
f({
produce: (n: string) => n,
consume: x => x.toLowerCase(),
});
// Was an error, now works.
f({
produce: n => n,
consume: x => x.toLowerCase(),
});
// Was an error, now works.
f({
produce: function () { return "hello"; },
consume: x => x.toLowerCase(),
});
// Was an error, now works.
f({
produce() { return "hello" },
consume: x => x.toLowerCase(),
});

在某些示例中推斷失敗,因為了解其 produce 函式的型別會間接要求在找到 T 的良好型別之前請求 arg 的型別。TypeScript 現在收集可能有助於 T 推斷型別的函式,並從它們中進行惰性推斷。

有關更多資訊,您可以檢視我們推斷過程的具體修改

例項化表示式

有時函式可能比我們想要的更通用。例如,假設我們有一個 makeBox 函式。

ts
interface Box<T> {
value: T;
}
function makeBox<T>(value: T) {
return { value };
}

也許我們想建立一組更專門的函式來製造 WrenchHammerBox。為了做到這一點,今天我們必須將 makeBox 包裝在其他函式中,或者為 makeBox 的別名使用顯式型別。

ts
function makeHammerBox(hammer: Hammer) {
return makeBox(hammer);
}
// or...
const makeWrenchBox: (wrench: Wrench) => Box<Wrench> = makeBox;

這些方法有效,但包裝對 makeBox 的呼叫有點浪費,編寫 makeWrenchBox 的完整簽名可能會變得繁瑣。理想情況下,我們能夠說我們只想為 makeBox 起別名,同時替換其簽名中的所有泛型。

TypeScript 4.7 允許做到這一點!我們現在可以直接獲取函式和建構函式並將型別引數直接餵給它們。

ts
const makeHammerBox = makeBox<Hammer>;
const makeWrenchBox = makeBox<Wrench>;

透過這種方式,我們可以專門化 makeBox 以接受更具體的型別並拒絕任何其他型別。

ts
const makeStringBox = makeBox<string>;
// TypeScript correctly rejects this.
makeStringBox(42);

此邏輯也適用於諸如 ArrayMapSet 之類的建構函式。

ts
// Has type `new () => Map<string, Error>`
const ErrorMap = Map<string, Error>;
// Has type `// Map<string, Error>`
const errorMap = new ErrorMap();

當函式或建構函式被賦予型別引數時,它將生成一個新型別,該型別保留所有具有相容型別引數列表的簽名,並將相應的型別引數替換為給定的型別引數。任何其他簽名都會被刪除,因為 TypeScript 會假設它們不打算被使用。

有關此功能的更多資訊,請檢視 pull request

infer 型別變數上的 extends 約束

條件型別是一種高階使用者功能。它們允許我們匹配和推斷型別的形狀,並根據它們做出決定。例如,我們可以編寫一個條件型別,如果元組型別是類 string 型別,則返回該元組型別的第一個元素。

ts
type FirstIfString<T> =
T extends [infer S, ...unknown[]]
? S extends string ? S : never
: never;
// string
type A = FirstIfString<[string, number, number]>;
// "hello"
type B = FirstIfString<["hello", number, number]>;
// "hello" | "world"
type C = FirstIfString<["hello" | "world", boolean]>;
// never
type D = FirstIfString<[boolean, number, string]>;

FirstIfString 匹配任何至少有一個元素的元組,並將第一個元素的型別提取為 S。然後它檢查 S 是否與 string 相容,如果是,則返回該型別。

請注意,我們必須使用兩個條件型別來編寫此程式碼。我們可以將 FirstIfString 寫成如下形式:

ts
type FirstIfString<T> =
T extends [string, ...unknown[]]
// Grab the first type out of `T`
? T[0]
: never;

這有效,但它稍微“手動”一些,宣告性較差。我們不必只對型別進行模式匹配並給第一個元素一個名稱,而是必須使用 T[0] 獲取 T 的第 0 個元素。如果我們處理的型別比元組更復雜,這可能會變得更加棘手,因此 infer 可以簡化事情。

使用巢狀條件型別來推斷型別並與該推斷型別匹配是很常見的。為了避免第二級巢狀,TypeScript 4.7 現在允許您在任何 infer 型別上放置約束。

ts
type FirstIfString<T> =
T extends [infer S extends string, ...unknown[]]
? S
: never;

這樣,當 TypeScript 匹配 S 時,它還確保 S 必須是 string。如果 S 不是 string,它會走 false 路徑,在這些情況下是 never

有關更多詳細資訊,您可以在 GitHub 上閱讀有關此更改的資訊

型別引數的可選方差註解

讓我們看一下以下型別。

ts
interface Animal {
animalStuff: any;
}
interface Dog extends Animal {
dogStuff: any;
}
// ...
type Getter<T> = () => T;
type Setter<T> = (value: T) => void;

想象我們有兩個不同的 Getter 例項。弄清楚任何兩個不同的 Getter 是否可以互相替代完全取決於 T。在 Getter<Dog>Getter<Animal> 的賦值是否有效的情況下,我們必須檢查 DogAnimal 是否有效。因為 T 的每種型別都以相同的“方向”相關聯,所以我們稱 Getter 型別在 T 上是協變 (covariant) 的。另一方面,檢查 Setter<Dog>Setter<Animal> 是否有效涉及到檢查 AnimalDog 是否有效。那種方向上的“翻轉”有點像數學中檢查 −x < −y 是否等同於檢查 y < x。當我們必須翻轉方向來比較 T 時,我們稱 SetterT 上是逆變 (contravariant) 的。

在 TypeScript 4.7 中,我們現在可以顯式指定型別引數的方差。

所以現在,如果我們想明確 GetterT 上是協變的,我們可以給它一個 out 修飾符。

ts
type Getter<out T> = () => T;

同樣,如果我們也想明確 SetterT 上是逆變的,我們可以給它一個 in 修飾符。

ts
type Setter<in T> = (value: T) => void;

這裡使用 outin 是因為型別引數的方差取決於它是在輸出還是輸入中使用。與其思考方差,您只需考慮 T 是否在輸出和輸入位置使用即可。

還有同時使用 inout 的情況。

ts
interface State<in out T> {
get: () => T;
set: (value: T) => void;
}

T 同時在輸出和輸入位置使用時,它就變成了不變 (invariant) 的。兩個不同的 State<T> 不能互換,除非它們的 T 相同。換句話說,State<Dog>State<Animal> 不能互相替代。

從技術上講,在純結構化型別系統中,型別引數及其方差並不重要——您只需用型別替換每個型別引數並檢查每個匹配的成員是否在結構上相容。那麼如果 TypeScript 使用結構化型別系統,我們為什麼要關注型別引數的方差?為什麼我們可能想註解它們?

一個原因是,對於讀者來說,一眼就能明確看出型別引數的使用方式是很有用的。對於更復雜的型別,很難判斷一個型別是被讀取、被寫入還是兩者兼有。如果我們忘記提及該型別引數的使用方式,TypeScript 也會幫助我們。例如,如果我們忘記在 State 上同時指定 inout,我們會得到一個錯誤。

ts
interface State<out T> {
// ~~~~~
// error!
// Type 'State<sub-T>' is not assignable to type 'State<super-T>' as implied by variance annotation.
// Types of property 'set' are incompatible.
// Type '(value: sub-T) => void' is not assignable to type '(value: super-T) => void'.
// Types of parameters 'value' and 'value' are incompatible.
// Type 'super-T' is not assignable to type 'sub-T'.
get: () => T;
set: (value: T) => void;
}

另一個原因是精度和速度!TypeScript 已經嘗試將型別引數的方差推斷作為最佳化手段。透過這樣做,它可以在合理的時間內檢查更大的結構化型別。提前計算方差允許型別檢查器跳過更深入的比較,而只比較型別引數,這比一遍又一遍地比較型別的完整結構要快得多。但通常情況下,這種計算仍然相當昂貴,並且計算可能會發現無法準確解決的迴圈,這意味著型別的方差沒有明確的答案。

ts
type Foo<T> = {
x: T;
f: Bar<T>;
}
type Bar<U> = (x: Baz<U[]>) => void;
type Baz<V> = {
value: Foo<V[]>;
}
declare let foo1: Foo<unknown>;
declare let foo2: Foo<string>;
foo1 = foo2; // Should be an error but isn't ❌
foo2 = foo1; // Error - correct ✅

提供顯式註解可以在這些迴圈處加速型別檢查並提供更好的精度。例如,在上面的示例中,將 T 標記為不變可以幫助阻止有問題的賦值。

diff
- type Foo<T> = {
+ type Foo<in out T> = {
x: T;
f: Bar<T>;
}

我們不一定建議為每個型別引數標註方差;例如,可能會(但不推薦)使方差比必要的更嚴格,因此如果您確實將其標記為不變(即使它實際上只是協變、逆變或甚至是獨立的),TypeScript 也不會阻止您。因此,如果您確實選擇新增顯式方差標記,我們鼓勵深思熟慮和精確地使用它們。

但如果您正在處理深度遞迴型別,尤其是如果您是庫作者,您可能會有興趣使用這些註解來造福您的使用者。這些註解可以在精度和型別檢查速度方面帶來收益,甚至可以影響他們的程式碼編輯體驗。確定何時方差計算成為型別檢查時間的瓶頸可以透過實驗完成,並使用諸如我們的 analyze-trace 工具來確定。

有關此功能的更多詳細資訊,您可以閱讀 pull request

使用 moduleSuffixes 進行解析定製

TypeScript 4.7 現在支援 moduleSuffixes 選項,用於自定義查詢模組說明符的方式。

jsonc
{
"compilerOptions": {
"moduleSuffixes": [".ios", ".native", ""]
}
}

鑑於上述配置,像下面這樣的匯入……

ts
import * as foo from "./foo";

將嘗試檢視相對檔案 ./foo.ios.ts./foo.native.ts,最後是 ./foo.ts

此功能對於 React Native 專案很有用,其中每個目標平臺可以使用具有不同 moduleSuffixes 的獨立 tsconfig.json

moduleSuffixes 選項感謝 Adam Foxman 的貢獻!

resolution-mode

使用 Node 的 ECMAScript 解析,包含檔案的模式和您使用的語法決定了匯入的解析方式;然而,從 ECMAScript 模組引用 CommonJS 模組的型別(反之亦然)將會很有用。

TypeScript 現在允許 /// <reference types="..." /> 指令。

ts
/// <reference types="pkg" resolution-mode="require" />
// or
/// <reference types="pkg" resolution-mode="import" />

此外,在 TypeScript 的 nightly 版本中,import type 可以指定一個匯入斷言來實現類似的效果。

ts
// Resolve `pkg` as if we were importing with a `require()`
import type { TypeFromRequire } from "pkg" assert {
"resolution-mode": "require"
};
// Resolve `pkg` as if we were importing with an `import`
import type { TypeFromImport } from "pkg" assert {
"resolution-mode": "import"
};
export interface MergedType extends TypeFromRequire, TypeFromImport {}

這些匯入斷言也可以用於 import() 型別。

ts
export type TypeFromRequire =
import("pkg", { assert: { "resolution-mode": "require" } }).TypeFromRequire;
export type TypeFromImport =
import("pkg", { assert: { "resolution-mode": "import" } }).TypeFromImport;
export interface MergedType extends TypeFromRequire, TypeFromImport {}

import typeimport() 語法僅在 TypeScript 的 nightly 構建版本中支援 resolution-mode。您很可能會收到類似以下的錯誤:

Resolution mode assertions are unstable. Use nightly TypeScript to silence this error. Try updating with 'npm install -D typescript@next'.

如果您確實在 TypeScript 的 nightly 版本中使用此功能,請考慮針對此問題提供反饋

您可以檢視引用指令型別匯入斷言的各自更改。

轉到源定義 (Go To Source Definition)

TypeScript 4.7 包含對一個新的實驗性編輯器命令的支援,稱為轉到源定義。它類似於轉到定義,但它從不會返回宣告檔案內的結果。相反,它嘗試找到相應的實現檔案(如 .js.ts 檔案),並在那裡找到定義——即使這些檔案通常被 .d.ts 檔案遮蓋。

這在您需要檢視從庫中匯入的函式的實現而不是其在 .d.ts 檔案中的型別宣告時最派上用場。

The "Go to Source Definition" command on a use of the yargs package jumps the editor to an index.cjs file in yargs.

您可以在最新版本的 Visual Studio Code 中嘗試此新命令。但請注意,此功能仍處於預覽階段,並且存在一些已知的侷限性。在某些情況下,TypeScript 使用啟發式方法來猜測哪個 .js 檔案對應於給定的定義結果,因此這些結果可能不準確。Visual Studio Code 也尚未指示結果是否為猜測,但這正是我們正在協作的內容。

您可以留下有關該功能的反饋,閱讀有關已知侷限性的資訊,或在我們專用的反饋問題中瞭解更多資訊。

分組感知組織匯入

TypeScript 具有針對 JavaScript 和 TypeScript 的組織匯入編輯器功能。不幸的是,它可能有點生硬,並且通常會天真地對您的匯入語句進行排序。

例如,如果您對以下檔案執行組織匯入……

ts
// local code
import * as bbb from "./bbb";
import * as ccc from "./ccc";
import * as aaa from "./aaa";
// built-ins
import * as path from "path";
import * as child_process from "child_process"
import * as fs from "fs";
// some code...

您會得到類似以下的結果:

ts
// local code
import * as child_process from "child_process";
import * as fs from "fs";
// built-ins
import * as path from "path";
import * as aaa from "./aaa";
import * as bbb from "./bbb";
import * as ccc from "./ccc";
// some code...

這……並不理想。當然,我們的匯入按其路徑排序,並且我們的註釋和換行符被保留,但不是以我們預期的那種方式。很多時候,如果我們以特定的方式對匯入進行分組,那麼我們希望保持這種方式。

TypeScript 4.7 以分組感知的方式執行組織匯入。在上述程式碼上執行它看起來更像您所期望的那樣:

ts
// local code
import * as aaa from "./aaa";
import * as bbb from "./bbb";
import * as ccc from "./ccc";
// built-ins
import * as child_process from "child_process";
import * as fs from "fs";
import * as path from "path";
// some code...

我們要感謝 Minh Quy 提供了此功能

物件方法片段補全

TypeScript 現在提供物件字面量方法的片段補全。在補全物件中的成員時,TypeScript 將提供僅包含方法名稱的常規補全條目,以及包含完整方法定義的單獨補全條目!

Completion a full method signature from an object

有關更多詳細資訊,請參閱實現該功能的 pull request

破壞性變更

lib.d.ts 更新

雖然 TypeScript 努力避免重大的破壞性變更,但即使內建庫中的微小更改也可能導致問題。我們預計 DOM 和 lib.d.ts 更新不會導致重大的破壞性變更,但可能存在一些小的變更。

JSX 中更嚴格的展開檢查

在 JSX 中編寫 ...spread 時,TypeScript 現在會執行更嚴格的檢查,以確保給定的型別實際上是一個物件。因此,型別為 unknownnever(以及更罕見的僅 nullundefined)的值不再能展開到 JSX 元素中。

所以對於以下示例:

tsx
import * as React from "react";
interface Props {
stuff?: string;
}
function MyComponent(props: unknown) {
return <div {...props} />;
}

您現在將收到類似以下的錯誤:

Spread types may only be created from object types.

這使得此行為與物件字面量中的展開更加一致。

有關更多詳細資訊,請參閱 GitHub 上的更改

模板字串表示式中更嚴格的檢查

symbol 值在模板字串中使用時,它會在 JavaScript 中觸發執行時錯誤。

js
let str = `hello ${Symbol()}`;
// TypeError: Cannot convert a Symbol value to a string

因此,TypeScript 也會發出錯誤;但是,TypeScript 現在還會檢查以某種方式約束為 symbol 的泛型值是否在模板字串中使用。

ts
function logKey<S extends string | symbol>(key: S): S {
// Now an error.
console.log(`${key} is the key`);
return key;
}
function get<T, K extends keyof T>(obj: T, key: K) {
// Now an error.
console.log(`Grabbing property '${key}'.`);
return obj[key];
}

TypeScript 現在將發出以下錯誤:

Implicit conversion of a 'symbol' to a 'string' will fail at runtime. Consider wrapping this expression in 'String(...)'.

在某些情況下,您可以透過將表示式包裝在對 String 的呼叫中來規避此問題,就像錯誤訊息建議的那樣。

ts
function logKey<S extends string | symbol>(key: S): S {
// No longer an error.
console.log(`${String(key)} is the key`);
return key;
}

在其他情況下,此錯誤太死板了,而且在使用 keyof 時,您可能根本不關心允許使用 symbol 鍵。在這些情況下,您可以切換到 string & keyof ...

ts
function get<T, K extends string & keyof T>(obj: T, key: K) {
// No longer an error.
console.log(`Grabbing property '${key}'.`);
return obj[key];
}

有關更多資訊,您可以檢視實現該功能的 pull request

readFile 方法在 LanguageServiceHost 上不再是可選的

如果您正在建立 LanguageService 例項,那麼提供的 LanguageServiceHost 將需要提供一個 readFile 方法。此更改對於支援新的 moduleDetection 編譯器選項是必要的。

您可以在此處閱讀有關此更改的更多資訊

readonly 元組具有 readonly length 屬性

readonly 元組現在將其 length 屬性視為 readonly。對於固定長度的元組,這幾乎從未被觀察到,但這是一個可以針對具有尾隨可選和剩餘元素型別的元組觀察到的疏忽。

因此,以下程式碼現在將失敗:

ts
function overwriteLength(tuple: readonly [string, string, string]) {
// Now errors.
tuple.length = 7;
}

您可以在此處閱讀有關此更改的更多資訊

TypeScript 文件是一個開源專案。透過傳送 Pull Request 幫助我們改進這些頁面 ❤

此頁面的貢獻者
ABAndrew Branch (9)
NTNémeth Tamás (1)
PADPBPedro Augusto de Paula Barbosa (1)
HCHyunyoung Cho (1)

最後更新:2026 年 3 月 27 日