2023 年 8 月 7 日

自訂錯誤,擴充 Error

當我們開發某些東西時,我們經常需要自己的錯誤類別來反映任務中可能出錯的特定事項。對於網路操作中的錯誤,我們可能需要 HttpError,對於資料庫操作,我們可能需要 DbError,對於搜尋操作,我們可能需要 NotFoundError,依此類推。

我們的錯誤應支援基本錯誤屬性,例如 messagename,最好還有 stack。但它們也可能具有自己的其他屬性,例如 HttpError 物件可能有 statusCode 屬性,其值為 404403500

JavaScript 允許對任何引數使用 throw,因此技術上我們的自訂錯誤類別不需要繼承自 Error。但如果我們繼承,則可以使用 obj instanceof Error 來識別錯誤物件。因此最好繼承自它。

隨著應用程式的發展,我們自己的錯誤自然會形成一個層級。例如,HttpTimeoutError 可能繼承自 HttpError,依此類推。

擴充 Error

舉例來說,我們考慮一個函式 readUser(json),它應該讀取包含使用者資料的 JSON。

以下是有效的 json 範例

let json = `{ "name": "John", "age": 30 }`;

在內部,我們將使用 JSON.parse。如果它收到格式錯誤的 json,它會擲出 SyntaxError。但即使 json 在語法上正確,也不代表它是一個有效的使用者,對吧?它可能會遺漏必要的資料。例如,它可能沒有我們使用者必要的 nameage 屬性。

我們的函式 readUser(json) 不僅會讀取 JSON,還會檢查(「驗證」)資料。如果沒有必要欄位,或格式錯誤,那是一個錯誤。那不是 SyntaxError,因為資料在語法上正確,而是另一種錯誤。我們將稱之為 ValidationError,並為它建立一個類別。這種錯誤也應該載有違規欄位的資訊。

我們的 ValidationError 類別應該繼承自 Error 類別。

Error 類別是內建的,但以下是它的近似程式碼,讓我們可以了解我們正在擴充什麼

// The "pseudocode" for the built-in Error class defined by JavaScript itself
class Error {
  constructor(message) {
    this.message = message;
    this.name = "Error"; // (different names for different built-in error classes)
    this.stack = <call stack>; // non-standard, but most environments support it
  }
}

現在讓我們從它繼承 ValidationError 並在實際情況中試用它

class ValidationError extends Error {
  constructor(message) {
    super(message); // (1)
    this.name = "ValidationError"; // (2)
  }
}

function test() {
  throw new ValidationError("Whoops!");
}

try {
  test();
} catch(err) {
  alert(err.message); // Whoops!
  alert(err.name); // ValidationError
  alert(err.stack); // a list of nested calls with line numbers for each
}

請注意:在第 (1) 行中,我們呼叫父建構函式。JavaScript 要求我們在子建構函式中呼叫 super,因此這是必要的。父建構函式設定 message 屬性。

父建構函式也會將 name 屬性設定為 "Error",因此在第 (2) 行中,我們將它重設為正確的值。

讓我們在 readUser(json) 中試用它

class ValidationError extends Error {
  constructor(message) {
    super(message);
    this.name = "ValidationError";
  }
}

// Usage
function readUser(json) {
  let user = JSON.parse(json);

  if (!user.age) {
    throw new ValidationError("No field: age");
  }
  if (!user.name) {
    throw new ValidationError("No field: name");
  }

  return user;
}

// Working example with try..catch

try {
  let user = readUser('{ "age": 25 }');
} catch (err) {
  if (err instanceof ValidationError) {
    alert("Invalid data: " + err.message); // Invalid data: No field: name
  } else if (err instanceof SyntaxError) { // (*)
    alert("JSON Syntax Error: " + err.message);
  } else {
    throw err; // unknown error, rethrow it (**)
  }
}

上述程式碼中的 try..catch 區塊處理我們自己的 ValidationError 和來自 JSON.parse 的內建 SyntaxError

請看我們如何在第 (*) 行中使用 instanceof 檢查特定錯誤類型。

我們也可以查看 err.name,如下所示

// ...
// instead of (err instanceof SyntaxError)
} else if (err.name == "SyntaxError") { // (*)
// ...

instanceof 版本好得多,因為未來我們將擴充 ValidationError,建立它的子類型,例如 PropertyRequiredError。而 instanceof 檢查將繼續適用於新的繼承類別。因此這是具有未來性的。

此外,如果 catch 遇見未知錯誤,它會在第 (**) 行中重新擲出它,這一點也很重要。catch 區塊只知道如何處理驗證和語法錯誤,其他種類(由程式碼中的錯字或其他未知原因造成)應該會穿透。

進一步繼承

ValidationError 類別非常通用。許多事情可能會出錯。屬性可能不存在,或者格式錯誤(例如 age 的字串值而不是數字)。讓我們建立一個更具體的類別 PropertyRequiredError,專門針對不存在的屬性。它將攜帶有關遺失屬性的附加資訊。

class ValidationError extends Error {
  constructor(message) {
    super(message);
    this.name = "ValidationError";
  }
}

class PropertyRequiredError extends ValidationError {
  constructor(property) {
    super("No property: " + property);
    this.name = "PropertyRequiredError";
    this.property = property;
  }
}

// Usage
function readUser(json) {
  let user = JSON.parse(json);

  if (!user.age) {
    throw new PropertyRequiredError("age");
  }
  if (!user.name) {
    throw new PropertyRequiredError("name");
  }

  return user;
}

// Working example with try..catch

try {
  let user = readUser('{ "age": 25 }');
} catch (err) {
  if (err instanceof ValidationError) {
    alert("Invalid data: " + err.message); // Invalid data: No property: name
    alert(err.name); // PropertyRequiredError
    alert(err.property); // name
  } else if (err instanceof SyntaxError) {
    alert("JSON Syntax Error: " + err.message);
  } else {
    throw err; // unknown error, rethrow it
  }
}

新的類別 PropertyRequiredError 很容易使用:我們只需要傳遞屬性名稱:new PropertyRequiredError(property)。人類可讀的 message 由建構函式產生。

請注意,PropertyRequiredError 建構函式中的 this.name 再次手動指定。這可能會變得有點繁瑣,在每個自訂錯誤類別中指定 this.name = <class name>。我們可以透過建立自己的「基本錯誤」類別來避免它,該類別指定 this.name = this.constructor.name。然後從中繼承我們所有自訂錯誤。

我們稱它為 MyError

以下是使用 MyError 和其他自訂錯誤類別的程式碼,簡化版

class MyError extends Error {
  constructor(message) {
    super(message);
    this.name = this.constructor.name;
  }
}

class ValidationError extends MyError { }

class PropertyRequiredError extends ValidationError {
  constructor(property) {
    super("No property: " + property);
    this.property = property;
  }
}

// name is correct
alert( new PropertyRequiredError("field").name ); // PropertyRequiredError

現在自訂錯誤短很多,特別是 ValidationError,因為我們在建構函式中刪除了 "this.name = ..." 行。

包裝例外

上述程式碼中 readUser 函式的目的是「讀取使用者資料」。過程中可能會發生不同類型的錯誤。目前我們有 SyntaxErrorValidationError,但未來 readUser 函式可能會擴充,並可能產生其他類型的錯誤。

呼叫 readUser 的程式碼應該處理這些錯誤。目前它在 catch 區塊中使用多個 if,檢查類別並處理已知錯誤,並重新擲出未知錯誤。

架構如下

try {
  ...
  readUser()  // the potential error source
  ...
} catch (err) {
  if (err instanceof ValidationError) {
    // handle validation errors
  } else if (err instanceof SyntaxError) {
    // handle syntax errors
  } else {
    throw err; // unknown error, rethrow it
  }
}

在上述程式碼中,我們可以看到兩種錯誤類型,但可能還有更多。

如果 readUser 函式產生多種類型的錯誤,那麼我們應該問自己:我們是否真的想每次逐一檢查所有錯誤類型?

答案通常是「否」:我們希望「高出所有這些錯誤一層」。我們只想了解是否有「資料讀取錯誤」,為什麼會發生通常無關緊要(錯誤訊息會說明)。或者,更好的是,我們希望有一種方法來取得錯誤詳細資料,但只有在需要時才取得。

我們在此描述的技術稱為「包裝例外」。

  1. 我們將建立一個新的類別 ReadError 來表示一般的「資料讀取」錯誤。
  2. 函式 readUser 會捕捉其內部發生的資料讀取錯誤,例如 ValidationErrorSyntaxError,並產生一個 ReadError
  3. ReadError 物件會在其 cause 屬性中保留對原始錯誤的參考。

然後呼叫 readUser 的程式碼只需要檢查 ReadError,而不是各種資料讀取錯誤。如果需要錯誤的更多詳細資訊,它可以檢查其 cause 屬性。

以下是定義 ReadError 並展示其在 readUsertry..catch 中使用的程式碼

class ReadError extends Error {
  constructor(message, cause) {
    super(message);
    this.cause = cause;
    this.name = 'ReadError';
  }
}

class ValidationError extends Error { /*...*/ }
class PropertyRequiredError extends ValidationError { /* ... */ }

function validateUser(user) {
  if (!user.age) {
    throw new PropertyRequiredError("age");
  }

  if (!user.name) {
    throw new PropertyRequiredError("name");
  }
}

function readUser(json) {
  let user;

  try {
    user = JSON.parse(json);
  } catch (err) {
    if (err instanceof SyntaxError) {
      throw new ReadError("Syntax Error", err);
    } else {
      throw err;
    }
  }

  try {
    validateUser(user);
  } catch (err) {
    if (err instanceof ValidationError) {
      throw new ReadError("Validation Error", err);
    } else {
      throw err;
    }
  }

}

try {
  readUser('{bad json}');
} catch (e) {
  if (e instanceof ReadError) {
    alert(e);
    // Original error: SyntaxError: Unexpected token b in JSON at position 1
    alert("Original error: " + e.cause);
  } else {
    throw e;
  }
}

在上面的程式碼中,readUser 的運作方式完全如說明所述,會捕捉語法和驗證錯誤,並擲出 ReadError 錯誤(未知錯誤會照常重新擲出)。

因此,外部程式碼檢查 instanceof ReadError 即可。無需列出所有可能的錯誤類型。

此方法稱為「包裝例外」,因為我們會採用「低階」例外,並將它們「包裝」成更抽象的 ReadError。它在物件導向程式設計中廣泛使用。

摘要

  • 我們可以正常繼承自 Error 和其他內建錯誤類別。我們只需要注意 name 屬性,並記得呼叫 super
  • 我們可以使用 instanceof 來檢查特定錯誤。它也適用於繼承。但有時我們會收到來自第三方函式庫的錯誤物件,而且沒有簡單的方法可以取得其類別。然後,可以使用 name 屬性進行此類檢查。
  • 包裝例外是一種廣泛使用的技術:函式會處理低階例外,並建立高階錯誤,而不是各種低階錯誤。低階例外有時會變成該物件的屬性,例如上述範例中的 err.cause,但這並非絕對必要。

任務

重要性:5

建立一個繼承自內建 SyntaxError 類別的 FormatError 類別。

它應該支援 messagenamestack 屬性。

使用範例

let err = new FormatError("formatting error");

alert( err.message ); // formatting error
alert( err.name ); // FormatError
alert( err.stack ); // stack

alert( err instanceof FormatError ); // true
alert( err instanceof SyntaxError ); // true (because inherits from SyntaxError)
class FormatError extends SyntaxError {
  constructor(message) {
    super(message);
    this.name = this.constructor.name;
  }
}

let err = new FormatError("formatting error");

alert( err.message ); // formatting error
alert( err.name ); // FormatError
alert( err.stack ); // stack

alert( err instanceof SyntaxError ); // true
教學課程地圖

留言

留言前請先閱讀…
  • 如果您有建議要如何改進,請 提交 GitHub 問題 或提交拉取請求,而不是留言。
  • 如果您無法理解文章中的某些內容,請詳細說明。
  • 若要插入幾行程式碼,請使用 <code> 標籤,若要插入多行,請將它們包在 <pre> 標籤中,若要插入超過 10 行,請使用沙盒(plnkrjsbincodepen…)