Developer Friendly Error Handling

打造開發者友善的錯誤處理方式(feat. Java, Go, Rust, TS)

前言

錯誤處理是所有程式都會遇到的問題,特別是在複雜的網頁開發中更是如此。本文將研究不同方法模式與語言探討如何「打造開發者友善的錯誤處理方式」。推薦演講: Unexpected Monad. Is Safe Error Handling Possible in JS/TS? by Artem Kobzar and Dmitry Makhnev - JSConf🔗

定義「錯誤」的差異: Exception 與 Error

  • Exception(例外):程式可以預期並處理的問題
    • 用戶輸入錯誤的帳號密碼
  • Error(錯誤):程式無法處理的嚴重問題
    • 系統當機、記憶體不足

JavaScript try catch 有什麼不足之處?

大多數語言都有 try-catch 機制,看起來很直觀,舉例 JavaScript:

try {
doThings()
} catch(error) {
console.error(error)
}

但實際使用時會遇到以下問題:

  1. 靜態型別分析:在弱型別語言中處理例外時由於沒有顯式類型,因此必須進行類型檢查或斷言。
try {
throw new Error('Error');
} catch (error) {
// error 可能是任何東西,需要運行時檢查
if (error instanceof Error) {
console.error(error.message);
}
}
  1. 佛系處理:沒有強制要求處理錯誤例外,容易被忽略。
  2. 例外錯誤難分:有任何問題都會被接住且不知道是哪裡拋出的錯誤,拆分多個 try catch 又影響閱讀。
try {
const request = { name: "test", value: 2n };
const body = JSON.stringify(request); // 可能出錯
const response = await fetch("https://example.com", { // 可能出錯
method: "POST",
body,
});
if (!response.ok) {
return;
}
// handle response // 也可能出錯
} catch (e) {
// 不知道是哪一步出錯了
return;
}

借鑒不同語言和生態的錯誤處理

Java 顯性處理錯誤

在 Java 中 Checked Exception 錯誤處理是顯性的,必須在程式碼裡寫出對錯誤的處理方式,否則連編譯都不會過。

class PositiveNumber {
private int value;
public PositiveNumber(int number) throws NegativeNumberException {
if (number <= 0) {
throw new NegativeNumberException("Number must be positive: " + number);
}
this.value = number;
}
public int getValue() {
return value;
}
}
PositiveNumber num = new PositiveNumber(-5); // 編譯失敗,因為沒處理例外

Node.js Callback Style 例外作為回傳數值

既然期望強制處理例外數值何不將例外當作執行結果回傳?像是早期 JavaScript Node.js 社群有個慣例是 Error First Callback 用於處理非同步代碼(當時連 Promise🔗Async Await🔗 都尚未推出),藉由回傳 Callback Function 「錯誤」依舊可以透過拋出攔截而「例外」被視為需要顯性處理的程式邏輯。

fs.readFile('foo.txt', (err, data) => {
if (err) {
// 處理錯誤
} else {
// 使用 data
}
})

但隨著程式邏輯複雜,Callback Hell🔗 不可避免,且這種模式需要開發者間的默契與配合。

Go 多回傳數值

沒有走 throw Error, try catch 的例外錯誤處理流程,而是依賴「多回傳數值 Multiple Return Values🔗」的語言功能特性同樣將例外視為數值回傳是 Go 社群的一種錯誤處理常態。

func main() {
file, err := os.Open("File.txt")
if err != nil {
log.Fatal(err)
}
fmt.Print(file)
}

也有人討厭 Go 沒有簡單的拋出例外錯誤機制全部依靠對返回的檢查來處理:

if err != nil {
if err != nil {
if err != nil {
}
}
}
if err != nil {
}
if err != nil {
}

Rust Result 型別

相較於 Go 習慣透過多回傳結果與錯誤,Rust 透過 Result<T, E> 型別顯式回傳「成功或失敗」,是強制且內建於語言當中的:

fn read_file() -> Result<String, std::io::Error> {
let content = std::fs::read_to_string("foo.txt")?;
Ok(content)
}
fn main() {
match read_file() {
Ok(data) => println!("內容: {}", data),
Err(e) => eprintln!("錯誤: {}", e),
}
}
  • Result<T, E>:一個列舉型別(Enum),只可能是 Ok(T),或是 Err(E),因此呼叫者必須顯性處理。
  • ? 運算子:語法糖,遇到 Err 時會自動向上回傳錯誤,讓程式更簡潔但仍維持顯性。

在這裡 Result Type 是一種 Monad 模式的體現,將運算過程透過包裝成「盒子」安全的對裡面的東西進行操作,不必擔心或檢查是否內容有錯誤。

fn safe_divide(x: i32, y: i32) -> Result<i32, String> {
if y == 0 {
Err("divide by zero".to_string())
} else {
Ok(x / y)
}
}
fn main() {
let result = Ok(2)
.map(|x| x + 4) // Ok(6)
.and_then(|x| safe_divide(x, 0)) // Err("divide by zero")
.map(|x| x * 2) // 跳過 (因為是 Err)
.map(|x| x - 1) // 跳過
.unwrap_or(-999); // 因為錯誤回傳 -999 當預設值
println!("{:?}", result); // -999
}

改善 JavaScript 錯誤處理體驗

Result Type

借鑒 Go 與 Rust 的「錯誤作為回傳數值」的錯誤處理模式,透過 TypeScript 定義 Result 型別搭配 okerr 函式建立回應物件可以很快達成類似的體驗:

type Result<T> = { success: true; value: T } | { success: false; error: Error };
function ok<T>(value: T): Result<T> {
return { success: true, value };
}
function err(error: Error): Result<never> {
return { success: false, error };
}
function hello(name: string): Result<string> {
if (name === "") {
return err(new Error("empty name"));
}
return ok(`Hi, ${name}. Welcome!`);
}
function main(): void {
const result = hello('');
if (result.success) {
console.log(result.value)
} else {
console.log(result.error)
}
}

Maybe Monad

class Maybe {
constructor(value) {
this.value = value;
}
static just(value) {
return new Maybe(value);
}
static nothing() {
return new Maybe(null);
}
isNothing() {
return this.value === null || this.value === undefined;
}
map(fn) {
if (this.isNothing()) {
return Maybe.nothing();
}
return Maybe.just(fn(this.value));
}
flatMap(fn) {
if (this.isNothing()) {
return Maybe.nothing();
}
return fn(this.value);
}
getOrElse(defaultValue) {
if (this.isNothing()) {
return defaultValue;
}
return this.value;
}
}
const safeDivide = (x, y) => {
if (y === 0) {
return Maybe.nothing();
}
return Maybe.just(x / y);
};
const result1 = Maybe.just(2)
.map(x => x + 4)
.flatMap(x => safeDivide(x, 3))
.map(x => x * 2)
.map(x => x - 1)
.getOrElse("Error!");
console.log(result1); // 3
const result2 = Maybe.just(2)
.map(x => x + 4)
.flatMap(x => safeDivide(x, 0))
.map(x => x * 2)
.map(x => x - 1)
.getOrElse("Error!");
console.log(result2); // Error!
class Maybe<T> {
private constructor(private readonly value: T | null) {}
static just<T>(value: T): Maybe<T> {
return new Maybe(value);
}
static nothing<T>(): Maybe<T> {
return new Maybe<T>(null);
}
isNothing(): boolean {
return this.value === null || this.value === undefined;
}
map<U>(fn: (value: T) => U): Maybe<U> {
if (this.isNothing()) {
return Maybe.nothing<U>();
}
return Maybe.just(fn(this.value!));
}
flatMap<U>(fn: (value: T) => Maybe<U>): Maybe<U> {
if (this.isNothing()) {
return Maybe.nothing<U>();
}
return fn(this.value!);
}
getOrElse(defaultValue: T): T {
if (this.isNothing()) {
return defaultValue;
}
return this.value!;
}
filter(predicate: (value: T) => boolean): Maybe<T> {
if (this.isNothing() || !predicate(this.value!)) {
return Maybe.nothing<T>();
}
return this;
}
toNullable(): T | null {
return this.value;
}
static fromNullable<T>(value: T | null | undefined): Maybe<T> {
return value != null ? Maybe.just(value) : Maybe.nothing<T>();
}
}
const safeDivide = (x: number, y: number): Maybe<number> => {
if (y === 0) {
return Maybe.nothing<number>();
}
return Maybe.just(x / y);
};
const result1 = Maybe.just(2)
.map(x => x + 4)
.flatMap(x => safeDivide(x, 3))
.map(x => x * 2)
.map(x => x - 1)
.getOrElse(-1);
console.log(result1); // 3
const result2 = Maybe.just(2)
.map(x => x + 4)
.flatMap(x => safeDivide(x, 0))
.map(x => x * 2)
.map(x => x - 1)
.getOrElse(-1);
console.log(result2); // -1
// 使用 fromNullable 的範例
const parseNumber = (str: string): Maybe<number> => {
const num = parseFloat(str);
return isNaN(num) ? Maybe.nothing<number>() : Maybe.just(num);
};
const calculation = parseNumber("10")
.flatMap(x => safeDivide(x, 2))
.map(x => x + 1)
.filter(x => x > 5)
.getOrElse(0);
console.log(calculation); // 6
// 處理可能為 null 的 API 回應
interface User {
id: number;
name: string;
email?: string;
}
const getUser = (): User | null => {
return { id: 1, name: "John", email: "[email protected]" };
};
const userEmail = Maybe.fromNullable(getUser())
.map(user => user.email)
.flatMap(email => Maybe.fromNullable(email))
.map(email => email.toLowerCase())
.getOrElse("[email protected]");
console.log(userEmail); // [email protected]

延伸閱讀