在上一篇 Maybe Functor 的文章中,可以看到 Maybe 讓我們的程式不會因為 null 或 undefined 而出錯、管線斷裂,即使物件屬性不存在,運算鏈也能安全地回傳 Nothing
,而不是讓應用程式直接爆炸。
不過,Nothing 雖然安全,卻也沉默。我們只知道「失敗了」,卻不知道「哪裡失敗、為什麼失敗」。這就是 Maybe 的限制:只能表達「有」或「無」,無法承載失敗的原因、上下文資訊。
今天要介紹的 Either Functor 是 Maybe 的健談(?兄弟,它是一個表示「可能錯誤」的容器,且它不只告訴你成功或失敗,還能在錯誤時提供清楚的原因。
讓我們先從一個 Maybe
無法完美解決的情境開始,假設我們正在開發一個使用者註冊表單,需要一個 validatePassword
來驗證使用者輸入的密碼。驗證規則如下:
根據我們上一篇文章學到的,很自然地會想到用 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 之前,先看看我們一般是如何處理錯誤的~
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 是另一種 Functor,前面說過,不同 Functor 之間的差異在於它們實作 map
的方式。而 Either 的 map
實作方式,就是它只沿著成功路徑前進:目前是 Right
才套用函數;若是 Left
,就保持原樣把錯誤往後傳。成功或失敗由 Left/Right
這個分支本身表達,並不是由 map
來判定。
Either 透過兩種狀態,或說兩個變體,來達成這個目的:
Right(value)
:表示計算成功,並持有成功的值。可以把它想成 Just 的對應物。Left(error)
:表示計算失敗,並持有錯誤資訊。這是 Nothing 的升級版,因為它可以攜帶錯誤相關的資訊。可以把 Either 想像成一個鐵路岔路口。一個計算要嘛順利地走上 Right
這條成功軌道,要嘛在遇到問題時被引導至 Left
這條失敗軌道。但無論火車(資料)走上哪條路,它都還在我們的軌道系統(型別系統)的掌控之中,永遠不會脫軌(拋出異常)。
接著我們來看看具體的程式實作,這裡一樣用 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);
Left
和 Right
是 Either 抽象型別的兩個子類別,這裡的關鍵在於 map
方法的兩種不同實現。Right.map
的行為和我們熟悉的 Maybe.Just.map
完全一樣。而 Left.map
則不同:它會完全忽略傳入的函數 fn
,並直接回傳 this
(也就是 Left
實例本身)。
Left
的短路行為和 Maybe 遇到空值的狀況類似,只是它不像 Maybe 一樣回傳 Nothing
,而是回傳值本身,這個短路行為正是 Either 作為 Functor 進行安全錯誤傳遞的核心機制。一旦計算鏈中出現了一個 Left
,它就像一個「紅色通行證」,後續所有 map
站點都會直接讓它通過而不做任何處理,將最初的錯誤資訊安全地傳遞到鏈的終點。
為了幫助我們理解這個過程,我們可以想像一個有兩種軌道的火車路徑如下示意圖。
圖 1 Either 的成功軌道(資料來源: 自行繪製)
圖 2 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, 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 })
為例,說明一下執行流程:
gradeExam(100)({ score: 95 })
→ Right(95)
map(feedback)
套用在 Right(95)
上 → Right('Your performance: Excellent')
map(console.log)
套用在 Right('...')
上 → 執行 console.log('Your performance: Excellent')
Right(undefined)
(因 console.log
無回傳值)若輸入不合法(如 examBot({ score: 150 })
),則會是這種流程:
gradeExam(100)
回傳 Left('Invalid score provided')
Left.map(f)
的實作為「不做任何事,直接回傳自己」
map(f) { return this; }
map(feedback)
、map(console.log)
都不會被執行
Left('Invalid score provided')
,且不會有任何印出如果要看完整可運作的程式碼,可參考此 CodePen 的 JavaScript 程式碼,有把輔助函式都定義好~
用以下幾點來總結今天的內容。
因為 Maybe 的 Nothing 無法告訴我們失敗的「原因」。在需要向使用者顯示具體錯誤或進行精確偵錯的情境下,Either 透過 Left(error) 提供了不可或缺的失敗上下文。
Either 是一個代表兩種可能性(通常是失敗或成功)的 Functor。它有 Right(value)
和 Left(error)
兩種狀態。它的 map
方法只會對 Right
中的值進行運算,一旦遇到 Left
,整個計算鏈就會「短路」,原封不動地將 Left
及其包含的錯誤資訊傳遞到最後。