長期以來,TypeScript一直被要求增加的一個特性是能夠描述函式可能丟擲的異常。這個特性通常被稱為"丟擲型別",在一些程式語言中用來幫助開發人員安全地呼叫函式。
例如,流行的強型別語言Java就使用throws
關鍵字來實現丟擲型別。開發人員可以從以下程式碼中的第一行推斷出,positive
函式可能會丟擲ValueException
異常:
public static void positive(int value) throws ValueException { /* ...*/ }
丟擲型別對於開發工具也非常有用,可以告訴編譯器在呼叫函式時可能會丟擲異常,而無需安全地使用try
/catch
處理。
那麼,為什麼TypeScript沒有包括丟擲型別呢?
簡而言之,這在TypeScript中是不可行的,而且有些人認為在大多數程式語言中也不實用。本文將深入探討包括丟擲型別在內的功能的優缺點以及一般性的阻礙因素。讓我們來看看吧!
丟擲型別的好處
開發人員通常希望知道一個函式可能丟擲的異常型別。將函式的異常描述與其引數和返回型別一起可以作為開發人員的有用文件。丟擲型別還允許語言的型別檢查器在呼叫函式時警告沒有適當錯誤處理的情況。
例如,如果之前的Assert.positive
Java方法在不處理該情況的方法中使用,Java將會報告編譯錯誤:
public void example() { Assert.positive(2); // ~ // Error: unreported exception ValueException; must be caught or declared to be thrown. }
另一種思考丟擲異常的方式是,它們描述了函式的第二種返回型別。函式可以返回值,也可以丟擲錯誤。傳統的型別註解註釋前者;丟擲型別文件化了後者。
在理論上,文件化潛在異常聽起來像是滿足最小驚訝原則的一個好方法:系統的行為不應該讓使用者感到意外。顯式地標記函式可能丟擲的異常型別可以減少函式丟擲異常時可能引起的意外。
檢查異常
丟擲型別通常與稱為"檢查異常"的特性一起使用,其中catch
子句能夠註釋它們可能捕獲的異常型別。例如,Java允許在捕獲的異常型別旁邊新增型別註釋,以執行特定於丟擲異常型別的邏輯。
這段假設的Java程式碼對捕獲的ValueException
執行特定邏輯,並且對其他Exception
執行更一般的邏輯:
public void example() { try { Assert.positive(-1); } catch (ValueException error) { System.out.println("Incorrect value: " + error.value); } catch (Exception error) { System.out.println("General error: " + error.message); } }
檢查異常對於基於捕獲異常型別執行不同邏輯很有用。像Java這樣的強型別語言可以強制執行根據捕獲異常型別執行正確的catch
塊。
丟擲型別的障礙
實際上,丟擲型別不可行的原因有很多。這些原因從常見的JavaScript實踐到在型別系統中真正表示丟擲型別的困難都有。
未經檢查的未型別化異常
在編碼中的一個不幸現實是,大多數程式碼行可能會意外地丟擲各種錯誤。即使看起來是型別安全的程式碼有時也會神秘地丟擲錯誤,包括物件的getter和setter。
例如,將陣列的length
設定為負數或太大的數字會丟擲RangeError
:
[].length = -1; // Runtime error thrown: "RangeError: Invalid array length"
使用者定義的getter和setter也可能會丟擲錯誤。以下的Counter
類故意在其count
屬性的getter中丟擲錯誤:
class Counter { #counted = 0; get count() { if (!this.#counted) { throw new Error("Not ready yet."); } return this.#counted; } increment() { this.#counted += 1; } } const { count } = new Counter(); // Runtime error thrown: "Error: Not ready yet."
更糟糕的是,JavaScript不能保證丟擲的物件是其內建Error
類的例項!以下程式碼,令人恐懼地,丟擲四種類型之一,其中兩種不是Error
例項:
function thisIsValidTypeScript() { switch (Math.floor(Math.random() * 4)) { case 0: throw new RangeError("Zero?!"); case 1: throw new Error("Gotcha!"); case 2: throw "a primitive string, not an Error"; case 3: throw null; } }
plugin:@typescript-eslint/only-throw-error
程式碼檢查規則可以強制編寫只會丟擲Error
的程式碼。但它僅檢查您自己的程式碼,而不檢查任何依賴項的程式碼。
因此,TypeScript不能預測catch
子句中錯誤的型別。它必須預設使用"頂級"型別(允許任何其他型別的型別):預設為any
,或者當啟用useUnknownInCatchVariables
時更安全的unknown
。
因此,在catch
塊中,TypeScript程式碼必須使用型別斷言和/或執行時型別檢查來縮小捕獲錯誤的型別:
try { thisIsValidTypeScript(); } catch (error) { if (error) { if (error instanceof RangeError) { console.warn("Out of range:", error.message); } else if (error instanceof Error) { console.warn("Caught an Error:", error.stack); } else { console.warn("Caught a non-Error:", error); } } else { console.error("I don't even know what this is:", error); } }
換句話說,即使函式可以有丟擲型別,它們的異常型別實際上仍然是unknown
。在像JavaScript這樣無法強制執行檢查異常型別的執行時中,丟擲型別的用處要小得多。
生態慣性
JavaScript和TypeScript開發人員沒有現有的文化來記錄函式可能丟擲的異常。沒有標準來表示哪些型別的函式呼叫或失敗情況應該由異常和/或精心設計的返回型別表示。因此,儘管許多非TypeScript包有良好設計的值返回型別,它們的潛在丟擲型別卻令人意外地複雜。
這個問題的複雜性在於JavaScript社羣喜歡建立許多依賴於彼此的小包。專案依賴樹中的每個包可能有不同的錯誤處理方法。填寫許多第三方包的丟擲型別將是一項巨大的任務,無論它是否會對TypeScript開發人員有利。
不需要
像Java這樣的舊語言之所以建立丟擲異常,部分原因是因為它們不支援更豐富的語言功能,如聯合型別。旨在結果為異常或某個值的方法無法返回Exception | Value
的聯合。相反,他們通常使用值返回"happy"路徑(Value
)和丟擲異常來返回"unhappy"路徑(Exception
)。
另一方面,JavaScript比許多傳統的強型別語言更靈活。JavaScript和TypeScript包括多種功能,使得管理"happy"和"unhappy"路徑更容易,包括後面描述的首選替代品:
First-class functions:提供行內函數,可以提供多個引數
Union types:允許返回值匹配多種可能的形狀
現在,許多JavaScript和TypeScript開發人員更喜歡使用這些語言的靈活功能來避免丟擲異常。透過這樣做,他們減少了程式碼丟擲異常的頻率,減少了對丟擲型別或檢查異常的需求。
型別系統複雜性
TypeScript語言的每一個新增都會增加其型別系統的複雜性。丟擲型別還需要考慮到型別檢查器對函式型別的可賦值性檢查。然而,能夠定義不過度報告有效程式碼的丟擲型別是棘手的。
以物件getter和setter可能丟擲錯誤的情況為例。如果設定陣列的length
屬性總是需要新增丟擲型別,對開發人員來說將會非常不便。但是,TypeScript無法在型別系統中表示比number
更精確的數值型別。對於可能觸發錯誤的數值沒有辦法在型別系統中知道。
其他複雜的型別系統問題包括:
介面和物件型別屬性應該如何表明它們可能丟擲錯誤?
如果程式碼沒有標記為丟擲異常,是否仍應允許在其周圍新增
try
塊?型別註解應該如何指示函式的引數可能是一個可以丟擲錯誤的函式,但這些錯誤不會被傳遞給呼叫程式碼?(例如
setTimeout
)
TypeScript新增丟擲型別將需要開發人員學習這些答案,以有效地編寫具有丟擲型別的型別安全函式。即使答案是直截了當的,這仍然增加了開發人員需要理解的內容,以編寫TypeScript程式碼。
首選替代方案
在使用型別化語言如TypeScript時,設計程式碼以便可以被語言的型別系統建模是很有用的。這樣做可以使型別系統更好地理解程式碼,併爲使用它的開發人員提供更多的幫助。
First-Class Functions
JavaScript以支援"first-class functions"而聞名,這意味著新函式可以作為函式引數和變數的值提供。許多在JavaScript中開發的API選擇使用first-class functions而不是丟擲異常。
例如,Node.js的fs.readFile
API在JavaScript Promise之前設計時要求開發人員提供在完成時呼叫的函式。該函式被呼叫時會傳遞兩個引數,err
和data
,其中只有一個會提供一個值:
import fs from "node:fs"; fs.readFile("data.txt", (err, data) => { if (err) { console.error("Oh no:", err); } else { console.log("Got data:", data.toString()); } });
許多傳統的強型別語言要麼從未支援內聯first-class函式,要麼僅最近開始支援。first-class函式是一種方便的方法,允許多種結果型別。
Union Types
JavaScript允許函式返回任意數量的不同資料型別。TypeScript使用聯合型別表示可能是幾種可能型別之一的值。
TypeScript開發人員可能會將丟擲Error
的函式切換為返回Error
或Value
:
考慮以下的getValueMaybe
函式,它要麼返回Value
,要麼丟擲Error
:
interface Value { /* ... */ } declare function createValue(): Value; declare function readyForValues(): Boolean; function getValueMaybe() { if (!readyForValues()) { throw new Error("Wait!"); } const value: Value = { /* ... */ }; return value; } try { const value = getValueMaybe(); console.log("Got a value:", value); } catch (error) { console.error("Not ready to get value:", error); }
getValueMaybe
函式的一個重構可以將其返回型別改為Value | Error
,其中Error
型別表示尚未能執行的情況。TypeScript的型別系統將強制要求程式碼處理Error
情況,而不是假設返回值是Value
:
declare function createValue(): Value; declare function readyForValues(): Boolean; interface Value { /* ... */ } // ---cut--- function getValueMaybe() { return readyForValues() ? createValue() : new Error("Wait!"); } const value = getValueMaybe(); if (value instanceof Error) { console.error("Not ready to get value:", value); } else { console.log("Got a value:", value); }
其他常見的聯合型別返回包括Value | undefined
,其中undefined
表示沒有Value
可用,或者區分聯合。
精確的準備狀態
更全面的重構可能會嘗試消除在可能丟擲錯誤的情況下呼叫函式的可能性。精明的TypeScript開發人員可能會喜歡之前的createValue()
函式不會在其readyForValues()
之前被使用。
重構可能會將createValue()
函式包裝在一個非同步的"工廠"中,只有在準備好呼叫時才返回該函式:
// @lib: dom,esnext // @module: nodenext // @target: esnext export interface Value { /* ... */ } // ---cut--- async function getValueCreator() { // (wait until the value is ready to be created) return function createValue() { const value: Value = { /* ... */ }; return value; }; } const createValue = await getValueCreator(); const value = await createValue(); console.log("Got a value:", value);
並非所有的程式碼都可以重構為諸如工廠函式這樣的替代策略。但是,在可能的情況下,工廠函式可以幫助使程式碼更自然地工作,避免錯誤狀態。
無論您選擇了哪種策略,都最好選擇一種可以在您的語言型別系統中清晰表示的策略。
結語
丟擲型別起初似乎是一個有用的想法,並且在幫助開發人員編寫更安全程式碼方面確實有一定的用處。但特別是在像JavaScript這樣更動態的生態系統中,其缺點遠遠超過了潛在的優勢。將其新增到TypeScript生態系統將是一項巨大的工作,收益遠低於在型別更為靈活的生態系統中的其他地方。
如果您希望實現更安全、可預測的函式呼叫,考慮使用其他策略。TypeScript中有幾種很好的替代方案,包括first-class函式和聯合型別。