前言
錯誤處理是所有程式都會遇到的問題,特別是在複雜的網頁開發中更是如此。本文將研究不同方法模式與語言探討如何「打造開發者友善的錯誤處理方式」。推薦演講: 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)}
但實際使用時會遇到以下問題:
- 靜態型別分析:在弱型別語言中處理例外時由於沒有顯式類型,因此必須進行類型檢查或斷言。
try { throw new Error('Error');} catch (error) { // error 可能是任何東西,需要運行時檢查 if (error instanceof Error) { console.error(error.message); }}
- 佛系處理:沒有強制要求處理錯誤例外,容易被忽略。
- 例外錯誤難分:有任何問題都會被接住且不知道是哪裡拋出的錯誤,拆分多個 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
型別搭配 ok
與 err
函式建立回應物件可以很快達成類似的體驗:
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 => {};
const userEmail = Maybe.fromNullable(getUser()) .map(user => user.email) .flatMap(email => Maybe.fromNullable(email)) .map(email => email.toLowerCase())
延伸閱讀
- 使用 JavaScript try…catch 來控制程式中的錯誤 - WebDong
- 如何處理 TypeScript 拋出的錯誤? - WebDong
- Golang style error handling pattern in Javascript - 5 Error
- 图解 Monad - 阮一峰的网络日志
- Monad is actually easy. (Conquering the Final Boss of Functional Programming)
- I Fixed Error Handling in JavaScript - Bret Cameron