JS 與 TS 的巢狀例外類別設計
JS 世界的例外處理一直處於相當混亂的狀態。下面是我認為的幾個原因:
任何東西都可以 throw
因為是弱型別,當初語言設計時並沒有規定只能throw Error type。因此你愛怎麼 throw 都是可以的。但 throw new Error() 的好處是,系統會幫你準備 stacktrace。
function throw1() {
throw "A";
}
function throw2() {
throw 404;
}
function throw3() {
throw { A: "123" };
}
function throw3() {
throw new Error("GG");
}
沒有標準的 Error
常見的 JS 的執行環境有 browser 和 nodejs,這些環境所定義的 Error 都有微妙的差別,而關於巢狀錯誤的設計 cause 近年來才納入標準。
- message
- name
- cause (直到 chrome 93 版才出現)
- fileName (非標準)
- lineNumber (非標準)
- columnNumber (非標準)
- stack (非標準)
- 沒有 name
- cause (直到 v16.9.0 才加入)
- code (和 MDN 不同,NodeJS 有相對穩定的 error code)
- message (OK)
- stack (OK)
例外處理的三個要求
這是我自己在設計例外處理類別時的三個要求。
- 能夠自訂錯誤類別
- 能夠顯示巢狀錯誤
- 能夠在例外發生時,一併放入當下環境的其他重要變數。
在 NodeJS 環境下,如果 runtime 不夠新,我會選擇安裝 nested-error-stacks 套件,他可以替我解決麻煩的 2。如果是 typescript 要再另外安裝 @types/nested-error-stacks,取得型別安全性。
使用上會像是這樣
import NestedError from "nested-error-stacks";
class MyError extends NestedError {
public context: { [key: string]: unknown } = {};
constructor(msg?: string, nested?: Error) {
super(msg, nested);
}
get name(): string {
return MyError.name;
}
}
nested-error-stack 解決的問題是,NestedError constructor 第一個參數放 message 描述錯誤,第二個參數放巢狀錯誤,而巢狀錯誤的描述會一起被放在最外層的 stack 裡 面。
使用上會變成
function fetch() {
throw new Error("network error");
}
function crawl() {
try {
fetch();
} catch (e) {
console.log(new MyError("crawl fail", e).stack);
}
}
可以看到 stack 清楚的串接了 nested error stack,這樣的錯誤訊息可以再被轉送到 logger 或是 bugsnag 等服務。
MyError: crawl fail
at crawl (file:///Users/---/--/src/github.com/ts-project/src/main.ts:67:18)
at file:///Users/---/---/src/github.com/ts-project/src/main.ts:71:1
at ModuleJob.run (node:internal/modules/esm/module_job:193:25)
at async Promise.all (index 0)
at async ESMLoader.import (node:internal/modules/esm/loader:533:24)
at async loadESM (node:internal/process/esm_loader:91:5)
at async handleMainPromise (node:internal/modules/run_main:65:12)
Caused By: Error: network error
at fetch (file:///Users/---/---/src/github.com/ts-project/src/main.ts:60:9)
at crawl (file:///Users/--/---/src/github.com/ts-project/src/main.ts:65:5)
at file:///Users/---/----/src/github.com/ts-project/src/main.ts:71:1
at ModuleJob.run (node:internal/modules/esm/module_job:193:25)
at async Promise.all (index 0)
at async ESMLoader.import (node:internal/modules/esm/loader:533:24)
at async loadESM (node:internal/process/esm_loader:91:5)
at async handleMainPromise (node:internal/modules/run_main:65:12)
name() 的目的是覆寫掉原本 NestedError 的 name
get name(): string {
return MyError.name
}
context 的目的是當例外發生時,可以一併把例外的執行環境一起存下來,供 debug 時參考。型別訂成 {[key: string]: unknown}
unknown 代表可以指派任意型別到 context 物件上,但是若是要讀取,必須先轉型,我想大多數的情況存下來的環境變數只用於 debug,unknown type 就夠用了。
相較於新版本的寫法
隨著 cause 屬性漸漸普及,接下來應該會看到越來越多這種寫法。
new Error("crawl fail", {cause: e}).stack)
這種寫法的 stack 上只會有當下這個 Error 的 callstack,不會有 nestedError 的 callstack。
Error: crawl fail
at crawl (file:///Users/---/src/github.com/ts-project/src/main.ts:67:18)
at file:///Users/---/src/github.com/ts-project/src/main.ts:71:1
at ModuleJob.run (node:internal/modules/esm/module_job:193:25)
at async Promise.all (index 0)
at async ESMLoader.import (node:internal/modules/esm/loader:530:24)
at async importModuleDynamicallyWrapper (node:internal/vm/module:438:15)
想要看更深的 stack 必須自行去 cause.stack 上面查找。我會比較喜歡一次把 stack 都放到最外層的寫法。