Skip to main content

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 近年來才納入標準。

看看 MDN 文件的 Error

  • message
  • name
  • cause (直到 chrome 93 版才出現)
  • fileName (非標準)
  • lineNumber (非標準)
  • columnNumber (非標準)
  • stack (非標準)

看看 NodeJS 的 Error

  • 沒有 name
  • cause (直到 v16.9.0 才加入)
  • code (和 MDN 不同,NodeJS 有相對穩定的 error code)
  • message (OK)
  • stack (OK)

例外處理的三個要求

這是我自己在設計例外處理類別時的三個要求。

  1. 能夠自訂錯誤類別
  2. 能夠顯示巢狀錯誤
  3. 能夠在例外發生時,一併放入當下環境的其他重要變數。

在 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 都放到最外層的寫法。