TypeScript 5.4

在最後一次賦值後閉包中保留型別收窄

TypeScript 通常可以根據你執行的檢查來為變數推斷出更具體的型別。這個過程被稱為“型別收窄”(narrowing)。

ts
function uppercaseStrings(x: string | number) {
if (typeof x === "string") {
// TypeScript knows 'x' is a 'string' here.
return x.toUpperCase();
}
}

一個常見的痛點是,這些收窄後的型別並不總是能在函式閉包中得以保留。

ts
function getUrls(url: string | URL, names: string[]) {
if (typeof url === "string") {
url = new URL(url);
}
return names.map(name => {
url.searchParams.set("name", name)
// ~~~~~~~~~~~~
// error!
// Property 'searchParams' does not exist on type 'string | URL'.
return url.toString();
});
}

在之前的情況下,TypeScript 認為假設 url 在回撥函式中確實是一個 URL 物件並不“安全”,因為它可能在其他地方被修改過;然而,在這個例子中,該箭頭函式總是在對 url 的那次賦值之後建立的,並且這確實是 url最後一次賦值。

TypeScript 5.4 利用了這一點,使型別收窄變得更加智慧。當引數和 let 變數在非提升(hoisted)的函式中使用時,型別檢查器會查詢最後一次賦值的點。如果找到了,TypeScript 就可以安全地從包含該函式的外部進行收窄。這意味著上面的例子現在可以直接工作了。

請注意,如果變數在巢狀函式中被賦值,則不會觸發收窄分析。這是因為無法確定該函式是否會在稍後被呼叫。

ts
function printValueLater(value: string | undefined) {
if (value === undefined) {
value = "missing!";
}
setTimeout(() => {
// Modifying 'value', even in a way that shouldn't affect
// its type, will invalidate type refinements in closures.
value = value;
}, 500);
setTimeout(() => {
console.log(value.toUpperCase());
// ~~~~~
// error! 'value' is possibly 'undefined'.
}, 1000);
}

這應該會讓許多典型的 JavaScript 程式碼更容易表達。你可以在 GitHub 上閱讀有關此更改的更多資訊

NoInfer 工具型別

在呼叫泛型函式時,TypeScript 能夠根據你傳入的任何內容推斷型別引數。

ts
function doSomething<T>(arg: T) {
// ...
}
// We can explicitly say that 'T' should be 'string'.
doSomething<string>("hello!");
// We can also just let the type of 'T' get inferred.
doSomething("hello!");

然而,一個挑戰是:什麼是“最佳”推斷型別並不總是顯而易見的。這可能導致 TypeScript 拒絕合法的呼叫、接受有問題的呼叫,或者在捕獲錯誤時提供較差的錯誤資訊。

例如,想象一個 createStreetLight 函式,它接受一個顏色名稱列表,以及一個可選的預設顏色。

ts
function createStreetLight<C extends string>(colors: C[], defaultColor?: C) {
// ...
}
createStreetLight(["red", "yellow", "green"], "red");

當我們傳入一個不在原始 colors 陣列中的 defaultColor 時會發生什麼?在這個函式中,colors 應該是“真值來源”,描述了可以傳給 defaultColor 的內容。

ts
// Oops! This is undesirable, but is allowed!
createStreetLight(["red", "yellow", "green"], "blue");

在這次呼叫中,型別推斷認為 "blue""red""yellow""green" 一樣合法。因此,TypeScript 沒有拒絕該呼叫,而是將 C 的型別推斷為 "red" | "yellow" | "green" | "blue"。你可能會說,推斷結果“搞砸了”(blue up in our faces)!

目前人們處理這個問題的一種方法是新增一個由現有型別引數約束的單獨型別引數。

ts
function createStreetLight<C extends string, D extends C>(colors: C[], defaultColor?: D) {
}
createStreetLight(["red", "yellow", "green"], "blue");
// ~~~~~~
// error!
// Argument of type '"blue"' is not assignable to parameter of type '"red" | "yellow" | "green" | undefined'.

這雖然可行,但有點笨拙,因為 D 可能不會在 createStreetLight 的其他簽名中使用。雖然在這種情況下不算太糟,但型別引數在簽名中僅使用一次通常是一種程式碼異味。

這就是 TypeScript 5.4 引入新的 NoInfer<T> 工具型別的原因。將型別用 NoInfer<...> 包裹起來,向 TypeScript 發出一個訊號:不要深入研究並匹配內部型別以尋找型別推斷的候選者。

使用 NoInfer,我們可以將 createStreetLight 重寫為類似這樣:

ts
function createStreetLight<C extends string>(colors: C[], defaultColor?: NoInfer<C>) {
// ...
}
createStreetLight(["red", "yellow", "green"], "blue");
// ~~~~~~
// error!
// Argument of type '"blue"' is not assignable to parameter of type '"red" | "yellow" | "green" | undefined'.

defaultColor 的型別排除在推斷範圍之外,意味著 "blue" 永遠不會成為推斷候選者,型別檢查器從而可以拒絕它。

你可以在 實現該功能的 PR 中檢視具體更改,以及感謝 Mateusz Burzyński 提供的 最初實現

Object.groupByMap.groupBy

TypeScript 5.4 增加了 JavaScript 新的 Object.groupByMap.groupBy 靜態方法的宣告。

Object.groupBy 接受一個可迭代物件,以及一個決定每個元素應放置在哪個“組”的函式。該函式需要為每個不同的組建立一個“鍵”,Object.groupBy 使用該鍵建立一個物件,其中每個鍵對映到一個包含原始元素的陣列。

因此,下面的 JavaScript 程式碼

js
const array = [0, 1, 2, 3, 4, 5];
const myObj = Object.groupBy(array, (num, index) => {
return num % 2 === 0 ? "even": "odd";
});

基本等同於編寫如下程式碼

js
const myObj = {
even: [0, 2, 4],
odd: [1, 3, 5],
};

Map.groupBy 類似,但產生的是一個 Map 而不是普通物件。如果你需要 Map 的保證、正在處理期望 Map 的 API,或者需要使用任何型別的鍵進行分組(不僅僅是能用作 JavaScript 屬性名稱的鍵),這可能更可取。

js
const myObj = Map.groupBy(array, (num, index) => {
return num % 2 === 0 ? "even" : "odd";
});

和之前一樣,你可以以等效的方式建立 myObj

js
const myObj = new Map();
myObj.set("even", [0, 2, 4]);
myObj.set("odd", [1, 3, 5]);

請注意,在上面的 Object.groupBy 示例中,生成的物件屬性全是可選的。

ts
interface EvenOdds {
even?: number[];
odd?: number[];
}
const myObj: EvenOdds = Object.groupBy(...);
myObj.even;
// ~~~~
// Error to access this under 'strictNullChecks'.

這是因為無法以通用的方式保證 groupBy 產生了所有可能的鍵。

還要注意,這些方法只有在將 target 配置為 esnext 或調整 lib 設定時才可用。我們預計它們最終將在穩定的 es2024 目標下可用。

我們要感謝 Kevin Gibbons 為這些 groupBy 方法新增宣告

--moduleResolution bundler--module preserve 中支援 require() 呼叫

TypeScript 有一個名為 bundlermoduleResolution 選項,旨在模擬現代打包工具(bundlers)確定匯入路徑指向哪個檔案的方式。該選項的侷限性之一是它必須與 --module esnext 配對使用,這使得無法使用 import ... = require(...) 語法。

ts
// previously errored
import myModule = require("module/path");

如果你計劃只編寫標準的 ECMAScript import,這可能沒什麼大不了的,但當使用帶有條件匯出(conditional exports)的包時,情況就不同了。

在 TypeScript 5.4 中,當將 module 設定為名為 preserve 的新選項時,現在可以使用 require()

結合 --module preserve--moduleResolution bundler,這兩者更準確地模擬了打包工具和 Bun 等執行時允許的內容以及它們執行模組查詢的方式。事實上,當使用 --module preserve 時,bundler 選項將隱式設定為 --moduleResolution(以及 --esModuleInterop--resolveJsonModule)。

json
{
"compilerOptions": {
"module": "preserve",
// ^ also implies:
// "moduleResolution": "bundler",
// "esModuleInterop": true,
// "resolveJsonModule": true,
// ...
}
}

--module preserve 下,ECMAScript 的 import 將始終原樣輸出,而 import ... = require(...) 將被輸出為 require() 呼叫(雖然實際上你甚至可能不會使用 TypeScript 進行輸出,因為你很可能會使用打包工具來處理程式碼)。無論包含檔案的副檔名如何,此規則都適用。因此,這段程式碼的輸出

ts
import * as foo from "some-package/foo";
import bar = require("some-package/bar");

看起來應該像這樣

js
import * as foo from "some-package/foo";
var bar = require("some-package/bar");

這也意味著你選擇的語法決定了如何匹配條件匯出。因此在上面的例子中,如果 some-packagepackage.json 看起來像這樣

json
{
"name": "some-package",
"version": "0.0.1",
"exports": {
"./foo": {
"import": "./esm/foo-from-import.mjs",
"require": "./cjs/foo-from-require.cjs"
},
"./bar": {
"import": "./esm/bar-from-import.mjs",
"require": "./cjs/bar-from-require.cjs"
}
}
}

TypeScript 會將這些路徑解析為 [...]/some-package/esm/foo-from-import.mjs[...]/some-package/cjs/bar-from-require.cjs

有關更多資訊,你可以在這裡閱讀關於這些新設定的內容

已檢查的匯入屬性和斷言

現在,匯入屬性和斷言會針對全域性的 ImportAttributes 型別進行檢查。這意味著執行時現在可以更準確地描述匯入屬性。

ts
// In some global file.
interface ImportAttributes {
type: "json";
}
// In some other module
import * as ns from "foo" with { type: "not-json" };
// ~~~~~~~~~~
// error!
//
// Type '{ type: "not-json"; }' is not assignable to type 'ImportAttributes'.
// Types of property 'type' are incompatible.
// Type '"not-json"' is not assignable to type '"json"'.

此更改感謝 Oleksandr Tarasiuk 提供。

新增缺失引數的快速修復

TypeScript 現在有一個快速修復功能,可以為引數過多呼叫的函式新增新引數。

A quick fix being offered when someFunction calls someHelperFunction with 2 more arguments than are expected.

The missing arguments have been added to someHelperFunction after the quick fix was applied.

這在需要將一個新引數傳遞給多個現有函式時很有用,目前這種操作可能比較繁瑣。

此快速修復Oleksandr Tarasiuk 友情提供。

TypeScript 5.0 中廢棄內容的後續更改

TypeScript 5.0 廢棄了以下選項和行為:

  • charset
  • target: ES3
  • importsNotUsedAsValues
  • noImplicitUseStrict
  • noStrictGenericChecks
  • keyofStringsOnly
  • suppressExcessPropertyErrors
  • suppressImplicitAnyIndexErrors
  • out
  • preserveValueImports
  • 專案引用中的 prepend
  • 隱式的特定於作業系統的 newLine

為了繼續使用它們,使用 TypeScript 5.0 及更高版本的開發者必須指定一個名為 ignoreDeprecations 的新選項,並將其值設為 "5.0"

然而,TypeScript 5.4 將是這些選項能繼續正常工作的最後一個版本。到 TypeScript 5.5(預計 2024 年 6 月),這些將成為硬性錯誤,使用它們的程式碼將需要進行遷移。

有關更多資訊,你可以在 GitHub 上閱讀該計劃,其中包含關於如何最好地調整程式碼庫的建議。

顯著的行為更改

本節重點介紹了一組值得注意的更改,在進行任何升級時都應予以確認和理解。有時它會突出顯示棄用、移除和新的限制。它也可能包含功能上有所改進但透過引入新錯誤而可能影響現有構建的錯誤修復。

lib.d.ts 變更

為 DOM 生成的型別可能會對程式碼庫的型別檢查產生影響。有關更多資訊,請參見 TypeScript 5.4 的 DOM 更新

更準確的條件型別約束

以下程式碼不再允許在 foo 函式中進行第二個變數宣告。

ts
type IsArray<T> = T extends any[] ? true : false;
function foo<U extends object>(x: IsArray<U>) {
let first: true = x; // Error
let second: false = x; // Error, but previously wasn't
}

以前,當 TypeScript 檢查 second 的初始化器時,它需要確定 IsArray<U> 是否可分配給單元型別 false。雖然 IsArray<U> 在任何明顯的方式下都不相容,但 TypeScript 也會檢視該型別的約束。在 T extends Foo ? TrueBranch : FalseBranch 這樣的條件型別中(T 是泛型),型別系統會檢視 T 的約束,將其代入 T 本身,並決定是採用真分支還是假分支。

但這種行為是不準確的,因為它過於急切。即使 T 的約束不可分配給 Foo,也不意味著它不會被例項化為符合條件的物件。因此,更正確的行為是在無法證明 T 絕不總是擴充套件 Foo 的情況下,為該條件型別的約束產生一個聯合型別。

TypeScript 5.4 採用了這種更準確的行為。在實踐中,這意味著你可能會發現某些條件型別的例項不再與它們的分支相容。

你可以在此處閱讀有關具體更改的資訊.

更積極地減少型別變數與原始型別之間的交集

TypeScript 現在更積極地減少型別變數與原始型別之間的交集,具體取決於型別變數的約束如何與這些原始型別重疊。

ts
declare function intersect<T, U>(x: T, y: U): T & U;
function foo<T extends "abc" | "def">(x: T, str: string, num: number) {
// Was 'T & string', now is just 'T'
let a = intersect(x, str);
// Was 'T & number', now is just 'never'
let b = intersect(x, num)
// Was '(T & "abc") | (T & "def")', now is just 'T'
let c = Math.random() < 0.5 ?
intersect(x, "abc") :
intersect(x, "def");
}

有關更多資訊,請參閱此處的更改

改進對帶有插值的模板字串的檢查

TypeScript 現在能更準確地檢查字串是否可分配給模板字串型別的佔位符槽位。

ts
function a<T extends {id: string}>() {
let x: `-${keyof T & string}`;
// Used to error, now doesn't.
x = "-id";
}

這種行為更理想,但可能會破壞使用條件型別等結構的程式碼,因為這些規則的變化很容易觀察到。

檢視此更改以獲取更多詳細資訊。

僅型別匯入與本地值衝突時的錯誤

以前,如果對 Something 的匯入僅指代型別,TypeScript 會在 isolatedModules 下允許以下程式碼。

ts
import { Something } from "./some/path";
let Something = 123;

然而,對於單檔案編譯器來說,假設刪除 import 是“安全”的並不合理,即使該程式碼在執行時註定會失敗。在 TypeScript 5.4 中,此程式碼將觸發如下錯誤:

Import 'Something' conflicts with local value, so must be declared with a type-only import when 'isolatedModules' is enabled.

解決方法應該是進行本地重新命名,或者如錯誤所述,將 type 修飾符新增到匯入中。

ts
import type { Something } from "./some/path";
// or
import { type Something } from "./some/path";

檢視有關此更改本身的更多資訊.

新的列舉可分配性限制

當兩個列舉具有相同的宣告名稱和列舉成員名稱時,它們以前總是被認為是相容的;然而,當數值已知時,TypeScript 會靜默允許它們具有不同的值。

TypeScript 5.4 收緊了這一限制,要求當列舉值已知時,它們必須完全相同。

ts
namespace First {
export enum SomeEnum {
A = 0,
B = 1,
}
}
namespace Second {
export enum SomeEnum {
A = 0,
B = 2,
}
}
function foo(x: First.SomeEnum, y: Second.SomeEnum) {
// Both used to be compatible - no longer the case,
// TypeScript errors with something like:
//
// Each declaration of 'SomeEnum.B' differs in its value, where '1' was expected but '2' was given.
x = y;
y = x;
}

此外,當其中一個列舉成員沒有靜態已知值時,也有新的限制。在這些情況下,另一個列舉必須至少是隱式數字型別的(例如,它沒有靜態解析的初始化器),或者是顯式數字型別的(意味著 TypeScript 可以將該值解析為數字)。實際上,這意味著字串列舉成員僅與具有相同值的其他字串列舉相容。

ts
namespace First {
export declare enum SomeEnum {
A,
B,
}
}
namespace Second {
export declare enum SomeEnum {
A,
B = "some known string",
}
}
function foo(x: First.SomeEnum, y: Second.SomeEnum) {
// Both used to be compatible - no longer the case,
// TypeScript errors with something like:
//
// One value of 'SomeEnum.B' is the string '"some known string"', and the other is assumed to be an unknown numeric value.
x = y;
y = x;
}

有關更多資訊,請參閱引入此更改的 PR

列舉成員的名稱限制

TypeScript 不再允許列舉成員使用 Infinity-InfinityNaN 作為名稱。

ts
// Errors on all of these:
//
// An enum member cannot have a numeric name.
enum E {
Infinity = 0,
"-Infinity" = 1,
NaN = 2,
}

在此處檢視更多詳細資訊.

在帶有 any 剩餘元素的元組上實現更好的對映型別保留

以前,將帶有 any 的對映型別應用於元組會建立 any 元素型別。這是不理想的,現已修復。

ts
Promise.all(["", ...([] as any)])
.then((result) => {
const head = result[0]; // 5.3: any, 5.4: string
const tail = result.slice(1); // 5.3 any, 5.4: any[]
});

有關更多資訊,請參閱修復方案以及關於行為更改的後續討論進一步的調整

輸出(Emit)更改

雖然這本身不是破壞性更改,但開發者可能隱式地依賴了 TypeScript 的 JavaScript 或宣告檔案輸出。以下是顯著的變化:

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

此頁面的貢獻者
Nnavya9singh (6)
IIC-EnzoD-FRA (1)

最後更新:2026 年 3 月 27 日