iT邦幫忙

2025 iThome 鐵人賽

DAY 18
0

https://ithelp.ithome.com.tw/upload/images/20251002/20168201NK0y1zyIf6.png

前言

在上一篇 Maybe Functor 的文章中,可以看到 Maybe 讓我們的程式不會因為 null 或 undefined 而出錯、管線斷裂,即使物件屬性不存在,運算鏈也能安全地回傳 Nothing,而不是讓應用程式直接爆炸。

不過,Nothing 雖然安全,卻也沉默。我們只知道「失敗了」,卻不知道「哪裡失敗、為什麼失敗」。這就是 Maybe 的限制:只能表達「有」或「無」,無法承載失敗的原因、上下文資訊。

今天要介紹的 Either Functor 是 Maybe 的健談(?兄弟,它是一個表示「可能錯誤」的容器,且它不只告訴你成功或失敗,還能在錯誤時提供清楚的原因。

為什麼我們需要 Either?

讓我們先從一個 Maybe 無法完美解決的情境開始,假設我們正在開發一個使用者註冊表單,需要一個 validatePassword 來驗證使用者輸入的密碼。驗證規則如下:

  1. 密碼不得為空。
  2. 密碼長度必須大於 8 個字元。
  3. 密碼必須包含至少一個數字。

根據我們上一篇文章學到的,很自然地會想到用 Maybe 來處理這個驗證邏輯。如果所有規則都通過,就回傳 Just(password);只要有任何一條規則驗證失敗,就返回 Nothing

以下程式碼用的 Maybe 實作和上一篇文章相同,這裡就不重複貼上~

// 驗證輔助函數
const trimInput = (pwd) => pwd.trim();
const notEmpty = (pwd) => (pwd.length > 0 ? pwd : null);
const minLength = (pwd) => (pwd.length > 8 ? pwd : null);
const hasNumber = (pwd) => (/\d/.test(pwd) ? pwd : null);

// 驗證流程 (使用 Maybe)
const validatePassword_Maybe = (password) => {
  return Maybe.of(password)
    .map(trimInput)  // 移除前後空白
    .map(notEmpty)   // 不得為空
    .map(minLength)  // 長度必須大於 8
    .map(hasNumber); // 必須包含至少一個數字
};

// 使用範例
console.log(validatePassword_Maybe("short"));       // Nothing {}
console.log(validatePassword_Maybe("longpassword")); // Nothing {}
console.log(validatePassword_Maybe(""));            // Nothing {}
console.log(validatePassword_Maybe("longpass123")); // Just {$value: 'longpass123'}

可以看到,不同的錯誤狀況最後都只會回傳 Nothing。那當我們的 UI 層收到 Nothing 時,它應該顯示什麼錯誤訊息呢?「密碼無效」?這對使用者來說沒什麼具體幫助。使用者會感到困惑:我的密碼是太短了?還是忘了加數字?或是根本不該為空?

這就是 Maybe 的限制:它只能告訴我們「失敗了」,卻無法告訴我們「為何失敗」。

Either 的出現正是為了解決這個問題。它不僅能表達成功或失敗,還能攜帶失敗的原因,例如 Left("Password too short")Left("Password needs a number")。失敗因此不再只是終點,而是可以被處理和傳遞的資料。

沒有 Either 的錯誤處理方式

在介紹 Either 之前,先看看我們一般是如何處理錯誤的~

try/catch 處理錯誤

看到錯誤處理,可能會有人想說,用 try/catch 不就能解決訊息傳遞的問題嗎?我們可以 throw 一個帶有具體訊息的 Error 物件。

// 傳統方式:使用 try/catch
const validatePassword_TryCatch = (password) => {
  let pwd = trimInput(password);

  if (!notEmpty(pwd)) {
    throw new Error("Password cannot be empty.");
  }
  if (!minLength(pwd)) {
    throw new Error("Password must be longer than 8 characters.");
  }
  if (!hasNumber(pwd)) {
    throw new Error("Password must contain a number.");
  }

  return pwd; // 驗證成功,返回原始密碼
};

// ✅ 成功案例
try {
  const valid = validatePassword_TryCatch("longpass123");
  console.log("Validation passed:", valid);
} catch (e) {
  console.error("Validation failed:", e.message);
}

// ❌ 失敗案例
try {
  const valid = validatePassword_TryCatch("short");
  console.log("Validation passed:", valid);
} catch (e) {
  console.error("Validation failed:", e.message);
}

雖然 try/catch 能傳遞錯誤資訊,但它帶來了副作用。在 functional programming 中,throw 會直接中斷正常流程,讓一個看似回傳字串的函數,實際上可能「直接爆炸」,破壞純粹性與可預測性,而且這方式強迫呼叫者一定要用 try/catch 包裹,讓原本流暢的函數組合變得笨拙。更簡單來說,這寫法不夠優雅、不夠 FP。

為了解決這問題,我們需要使用 Either,接著就來看看 Either 是什麼吧~

所以 Either 是什麼?

Either 是另一種 Functor,前面說過,不同 Functor 之間的差異在於它們實作 map 的方式。而 Either 的 map 實作方式,就是它只沿著成功路徑前進:目前是 Right 才套用函數;若是 Left,就保持原樣把錯誤往後傳。成功或失敗由 Left/Right 這個分支本身表達,並不是由 map 來判定。

Either 透過兩種狀態,或說兩個變體,來達成這個目的:

  • Right(value):表示計算成功,並持有成功的值。可以把它想成 Just 的對應物。
  • Left(error):表示計算失敗,並持有錯誤資訊。這是 Nothing 的升級版,因為它可以攜帶錯誤相關的資訊。

可以把 Either 想像成一個鐵路岔路口。一個計算要嘛順利地走上 Right 這條成功軌道,要嘛在遇到問題時被引導至 Left 這條失敗軌道。但無論火車(資料)走上哪條路,它都還在我們的軌道系統(型別系統)的掌控之中,永遠不會脫軌(拋出異常)。

Either 的實作

接著我們來看看具體的程式實作,這裡一樣用 JavaScript 來實現 Either。(完整可見連結

class Either {
  static of(x) {
    return new Right(x); // 預設成功分支,這是一個慣例,因為通常我們關注的是成功的鏈式運算,錯誤則用 Left 標註
  }

  constructor(x) {
    this.$value = x;
  }
}

class Left extends Either {
  map(f) {
    return this; // 失敗分支不做映射,直接回傳自身
  }

  inspect() { // 這主要用來方便除錯,不是必須的方法
    return `Left(${inspect(this.$value)})`;
  }
}

class Right extends Either {
  map(f) {
    return Either.of(f(this.$value)); // 只對成功分支做映射;結果一律再包回 Either
  }

  inspect() { // 這主要用來方便除錯,不是必須的方法
    return `Right(${inspect(this.$value)})`;
  }
}

const left = x => new Left(x);

LeftRight 是 Either 抽象型別的兩個子類別,這裡的關鍵在於 map 方法的兩種不同實現。Right.map 的行為和我們熟悉的 Maybe.Just.map 完全一樣。而 Left.map 則不同:它會完全忽略傳入的函數 fn,並直接回傳 this(也就是 Left 實例本身)。

Left 的短路行為和 Maybe 遇到空值的狀況類似,只是它不像 Maybe 一樣回傳 Nothing,而是回傳值本身,這個短路行為正是 Either 作為 Functor 進行安全錯誤傳遞的核心機制。一旦計算鏈中出現了一個 Left,它就像一個「紅色通行證」,後續所有 map 站點都會直接讓它通過而不做任何處理,將最初的錯誤資訊安全地傳遞到鏈的終點。

為了幫助我們理解這個過程,我們可以想像一個有兩種軌道的火車路徑如下示意圖。
https://ithelp.ithome.com.tw/upload/images/20251002/20168201c7tbL63rU5.png
圖 1 Either 的成功軌道(資料來源: 自行繪製)

https://ithelp.ithome.com.tw/upload/images/20251002/20168201CQZYPCD2a4.png
圖 2 Either 的失敗軌道(資料來源: 自行繪製)

有了 Either 的區別:重構前後的比較

回到最初的密碼驗證問題,看看 Either 如何解決,這裡先簡單用 left('錯誤訊息') 來回傳出錯誤的原因,因為不同情況要塞不同的錯誤原因,所以還是有部分的 if 判斷,雖然可以再進一步函數化(composable),但可能會再用到其他進階 FP 工具,所以目前先保持這個版本。

// validatePassword_Either :: String -> Either(String, String)
const validatePassword_Either = (password) => {
  const s1 = password == null ? '' : trimInput(String(password));

  const s2 = notEmpty(s1);
  if (s2 == null) return left("Password cannot be empty.");

  const s3 = minLength(s2);
  if (s3 == null) return left("Password must be longer than 8 characters.");

  const s4 = hasNumber(s3);
  if (s4 == null) return left("Password must contain a number.");

  return Either.of(s4);
};

// 範例
const r1 = validatePassword_Either("short");          // Left {$value: 'Password must be longer than 8 characters.'}
const r2 = validatePassword_Either("longpassword");   // Left {$value: 'Password must contain a number.'}
const r3 = validatePassword_Either("longpass123");    // Right {$value: 'longpass123'}

現在每一個失敗都攜帶了精確的錯誤訊息。我們不僅知道失敗了,還清楚地知道為什麼失敗。最重要的是,validatePassword_Either 函數仍然是純函數。它沒有拋出任何異常,只是誠實地返回一個值——一個 Either 實例,這個值完整地描述了所有可能的結果。

Either 的更多範例

再看一個 Either 應用範例,假設現在有個「線上考試評分機」,會依據學生的分數給予不同評語:

// 假設已有 Either, left, Right, map, compose, curry,可參考之前文章的函式定義

// gradeExam :: Number -> Student -> Either(String, Number)
// Number 是滿分的分數,Student 物件包含 score
const gradeExam = curry((maxScore, student) => {
  const { score } = student;
  return (typeof score === 'number' && score >= 0 && score <= maxScore)
    ? Either.of(score)
    : left('Invalid score provided');
});


// scoreToFeedback :: Number -> String
const scoreToFeedback = (s) =>
  s >= 90 ? 'Excellent'
  : s >= 60 ? 'Pass'
  : 'Fail';


// feedback :: Number -> String
const feedback = compose(
  concat('Your performance: '),
  scoreToFeedback
);

// examBot :: Student -> Either(String, _)
const examBot = compose(
  map(console.log),   // 顯示訊息
  map(feedback),      // 把分數轉成文字評語
  gradeExam(100)      // 部分套用,固定滿分 100
);

說明一下上述函式的型別和語意:

  • gradeExam(100) :: Student -> Either(String, Number)
    • Right(Number):合法分數(0 ~ 100),攜帶的是原始分數
    • Left(String):不合法(型別錯或超出範圍),攜帶錯誤訊息
  • feedback :: Number -> String:純函式,只處理普通數值,不感知容器
  • examBot :: Student -> Either(String, _):右邊的 _ 表示 可忽略的值(console.log 沒回傳值,可視為回傳 undefined),但仍被包在 Right 裡,保留了「整體流程成功」的事實。

使用範例如下:

examBot({ score: 95 });
// "Your performance: Excellent"
// Right(undefined)

examBot({ score: 45 });
// "Your performance: Fail"
// Right(undefined)

examBot({ score: 150 });
// Left("Invalid score provided")

examBot({ score: 95 }) 為例,說明一下執行流程:

  1. gradeExam(100)({ score: 95 })Right(95)
  2. map(feedback) 套用在 Right(95) 上 → Right('Your performance: Excellent')
  3. map(console.log) 套用在 Right('...') 上 → 執行 console.log('Your performance: Excellent')
  4. 最終回傳 Right(undefined)(因 console.log 無回傳值)

若輸入不合法(如 examBot({ score: 150 })),則會是這種流程:

  1. gradeExam(100) 回傳 Left('Invalid score provided')
  2. Left.map(f) 的實作為「不做任何事,直接回傳自己」
    map(f) { return this; }
    
  3. 之後的 map(feedback)map(console.log) 都不會被執行
    • 最終仍是 Left('Invalid score provided'),且不會有任何印出

如果要看完整可運作的程式碼,可參考此 CodePen 的 JavaScript 程式碼,有把輔助函式都定義好~

小結

用以下幾點來總結今天的內容。

為什麼要有 Either?

因為 Maybe 的 Nothing 無法告訴我們失敗的「原因」。在需要向使用者顯示具體錯誤或進行精確偵錯的情境下,Either 透過 Left(error) 提供了不可或缺的失敗上下文。

沒有 Either 跟有 Either 的區別是什麼?

  • 沒有 Either: 我們被迫使用 try/catch,這會破壞函數的純粹性和可組合性;或者使用 Maybe,但會丟失寶貴的錯誤資訊。
  • 有 Either: 我們將錯誤視為資料,讓錯誤處理流程變得明確、安全且可組合。

所以 Either 是什麼?

Either 是一個代表兩種可能性(通常是失敗或成功)的 Functor。它有 Right(value)Left(error) 兩種狀態。它的 map 方法只會對 Right 中的值進行運算,一旦遇到 Left,整個計算鏈就會「短路」,原封不動地將 Left 及其包含的錯誤資訊傳遞到最後。

Reference


上一篇
[Day 17] Maybe Functor:處理空值
系列文
30 天的 Functional Programming 之旅18
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言