你的函式簽名說它回傳 User。其實不然。它回傳 User,不然就爆炸。型別系統根本不知道有第二條路。
這就是基於例外的錯誤處理最根本的不誠實。每一次 throw 都是一條編譯器看不見、無法檢查、也無法強制的控制流路徑。你會得到型別檢查完美通過、卻還是在正式環境崩潰的程式碼,只因為有人在三層呼叫之外忘了寫 try/catch。
明確回傳錯誤可以解決這個問題。它讓失敗成為型別系統的一等公民。問題在於,為了這份誠實,人體工學上的代價是否值得。
型別層級正確性對錯誤處理意味著什麼
型別層級正確性意味著你的型別會誠實告知函式可能產出的結果。如果函式可能失敗,回傳型別就應該反映出來;如果不可能失敗,回傳型別就應該保證成功。
Rust 用 Result<T, E> 來表達這件事:
fn parse_port(s: &str) -> Result<u16, ParseIntError> {
s.parse()
}
這個簽名是完整的。呼叫者知道有兩種結果。編譯器會強制他們兩者都要處理。
TypeScript 預設則不然。一個會拋出的函式,看起來跟不會拋出的函式一模一樣:
function parsePort(s: string): number {
const n = parseInt(s, 10);
if (isNaN(n) || n < 0 || n > 65535) {
throw new Error(`Invalid port: ${s}`);
}
return n;
}
回傳型別寫的是 number。它應該寫 number | Error,或者更精確地說,Result<number, Error>。但它沒有,所以呼叫者完全沒有警訊,不知道自己需要防禦失敗。
拋出的錯誤如何躲在你的呼叫圖裡
傷害會隨著呼叫堆疊加深而累積。葉節點函式拋出的錯誤,會迫使每一層middleware要不是捕捉它,就是隱含地傳播它。這些傳播路徑全都不會出現在任何型別簽名裡。
想像一個設定載入器呼叫parser,parser再呼叫驗證器。如果驗證器拋出錯誤,parser和載入器也變得會拋錯。但它們的型別不會改變。你看著 loadConfig() 的簽名,根本無法知道它可能會失敗。你得讀遍每個相依元件的每一行程式碼,或者等執行時崩潰。
這與型別應有的作用完全相反。型別的存在就是為了讓你不必讀實作就能理解合約。
明確回傳錯誤翻轉了這種情況。失敗路徑在每一層都看得見:
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
function parsePort(s: string): Result<number, string> {
const n = parseInt(s, 10);
if (isNaN(n) || n < 0 || n > 65535) {
return { ok: false, error: `Invalid port: ${s}` };
}
return { ok: true, value: n };
}
function loadConfig(raw: Record<string, string>): Result<Config, string> {
const portResult = parsePort(raw.port);
if (!portResult.ok) return portResult;
const host = raw.host || "localhost";
return { ok: true, value: { port: portResult.value, host } };
}
現在 loadConfig 的簽名說明了一切。你還沒讀到函式本體的任何一行,就知道它可能會失敗。
真正的取捨在人體工學,而非表達力
誠實的做法比較冗長。沒人喜歡寫 { ok: false, error: ... } 而不是 throw new Error(...)。視覺雜訊會累積,尤其是當你要鏈接多個可能失敗的操作時。
這就是為什麼大多數支援 checked exception 的語言後來都放棄了它。Java 的 checked exception 試圖在型別層級強制明確處理,但語法太過嚴苛。Rust 的 ? 運算子以及 Result 的組合子如 map 和 and_then 則找回了大部分的人體工學。TypeScript 沒有 ? 運算子,但你可以用輔助函式接近同樣的效果:
function map<T, U, E>(result: Result<T, E>, fn: (value: T) => U): Result<U, E> {
return result.ok ? { ok: true, value: fn(result.value) } : result;
}
function flatMap<T, U, E>(
result: Result<T, E>,
fn: (value: T) => Result<U, E>
): Result<U, E> {
return result.ok ? fn(result.value) : result;
}
它仍然比 Rust 笨拙。如果你在寫 TypeScript,neverthrow 這類函式庫提供了帶有鏈式方法的 Result 型別,用起來幾乎像原生的一樣。確實有額外負擔,但一旦有了輔助工具,實際代價比看起來小得多。
還有另一項成本。拋出錯誤的stack trace既豐富又自動。回傳的錯誤只是值。如果你想要stack trace,必須自己建構。對除錯來說,這很重要;但對業務邏輯中可預期的錯誤處理來說,通常沒那麼重要。
什麼時候拋出仍然合理
我們並不主張完全零例外。有些失敗確實是「例外」的,不該納入你正常的錯誤模型。設定載入時找不到檔案是預期中的失敗;記憶體不足則不是。
經驗法則是:如果失敗屬於你的領域的一部分,就回傳它;如果是程式臭蟲或災難性的系統故障,就拋出它。JSON.parse 在輸入格式錯誤時拋出很煩人,因為無效 JSON 很常見;Array.prototype.map 在 null 陣列上拋出則沒問題,因為對 null 呼叫 .map 本身就是臭蟲。
實務上,這表示你的 I/O 邊界、parser、驗證器和業務規則應該回傳錯誤。你的不變條件、斷言,以及真正災難性的狀況,仍然可以拋出。
從 API 邊界開始
你不需要重構整個程式碼庫。採用明確錯誤回傳價值最高的地方,是你的公開 API 和 I/O 層。這些正是呼叫者需要知道什麼可能出錯的邊界。
挑一個模組。把它的回傳型別改成 Result<T, E>。更新它的呼叫者。看看感覺如何。這個模式有種特殊的成癮性:一旦你不再為了尋找遺漏的 try/catch 而翻遍stack trace,就會意識到拋出的例外過去消耗了你多少心智負擔。
錯誤的型別層級正確性,目的不是寫出完美的程式碼,而是寫出更少謊言的程式碼。