
在過去幾篇文章中,我們認識了 Functor 這個 FP 工具,透過 .map,我們學會了如何在一個「容器」或「上下文 (context)」內,對值進行操作,而完全不用擔心容器本身的結構。無論是可能為空的 Maybe、帶有錯誤分支的 Either,還是封裝著副作用的 IO 與 Task,Functor 都讓我們能以一種優雅且可組合的方式來建立資料處理管道。
這一切看起來非常美好,我們的函數組合 (compose 或 pipe) 如絲般順滑。只要我們的函數是單純地從 a 一般值轉換到 b 一般值(a -> b),Functor 的世界就完美無瑕。
但現在有個問題:「如果我們想要 map 的那個函式,它本身的回傳值也是一個容器呢?例如 a -> M(b) 這樣?」
當我們將 M(b) 組合到下一個資料處理管道時,就像是為一顆洋蔥包上了另一層皮。我們得到的不是 Maybe(user),而是 Maybe(Maybe(user));不是 IO(data),而是 IO(IO(data))。

圖 1 Functor 的 .map 處理回傳容器的函數時,會產生巢狀結構(資料來源: 自行繪製)
這就是「巢狀洋蔥」問題,而今天要介紹的 Monad 就是為了解決這問題,以便我們繼續流暢的組合,接著就來看看吧~
of 的意義在探討 Monad 如何解決巢狀問題之前,先回顧一個我們已經熟悉,但可能還未完全理解其重要性的方法:.of。
一開始我們可能認為 Maybe.of(x) 只是 new Maybe(x) 的一種語法糖,或是一種避免使用 new 關鍵字的 FP 風格。但它的意義不止於此。
一個實作了 of 方法的 Functor,我們稱之為 Pointed Functor。
.of 的真正目的,是提供一個標準化的介面,將任何一個「一般值世界」的值,放入該 Functor 的「預設最小脈絡 (default minimal context)」中。它回答了這個問題:「如果要把一個一般值放進這個容器裡,最安全、最通用的方式是什麼?」
每個 Functor 只能有一種放入值的方式,以 Either 為例,Either 有 Left 和 Right 兩種狀態,但只有 Right 是可以被 .map 的。因此,Either 的「預設最小脈絡」就是 Right。這就是為什麼 Either.of(5) 的結果會是 Right(5),而不是 Left(5)。Left.of 在概念上是沒有意義的,因為 Left 代表計算的中斷,而不是一個可以繼續操作的容器。
在不同的函式庫或文獻中,of 也被稱為 pure、unit 或 return。它們本質上都在描述相同的功能:將一個一般值「提升」到容器的脈絡中,換句話說,of 會將一個值從「一般值的世界」提升到「容器包裹值」的世界。
圖 2 of 會將一個值從「一般值的世界」提升到「容器包裹值」的世界(資料來源: 自行繪製)
理解了 Pointed Functor,就比較能理解 Monad 的定義,稍後會看到,Monad 的定義就是:「一個可以被壓平的 Pointed Functor」。
先從一個熟悉的 Maybe Functor 開始,看看它在巢狀組合中的問題。
假設我們要處理一個使用者物件,目標是取得該使用者地址中的街道內容。這過程有三個步驟,且每一步都可能失敗:
簡單來說這是一個 user.addresses[0].street 的巢狀取值,每一層取值都可能遇到值不存在的狀況,為了處理這種「可能不存在」的情況,我們可使用 Maybe,並定義兩個「安全」的函式,它們會將可能為 null 或 undefined 的結果包裝進 Maybe 容器中:
// --- 小工具 -----------------------------------------------------------
// compose :: ((b -> c), (a -> b)...) -> a -> c
const compose = (...fns) => x => fns.reduceRight((v, f) => f(v), x);
// curry :: ((a, b, ...) -> c) -> a -> b -> ... -> c
const curry = (fn) => {
const arity = fn.length;
const $curry = (...args) => {
if (args.length < arity) {
return $curry.bind(null, ...args);
}
return fn.call(null, ...args);
};
return $curry;
};
// map :: Functor f => (a -> b) -> f a -> f b
const map = curry((fn, f) => f.map(fn));
// --- Maybe -------------------------------------------------------
const Maybe = {
of: (value) =>
value === null || value === undefined ? new Nothing() : new Just(value)
};
class Just {
constructor(value) {
this.$value = value;
}
map(fn) {
return Maybe.of(fn(this.$value));
}
getOrElse(defaultValue) {
return this.$value;
}
toString() {
return `Just(${this.$value})`;
}
}
class Nothing {
map(fn) { return this; }
getOrElse(defaultValue) { return defaultValue; }
toString() { return 'Nothing()'; }
}
// --- 安全取值的函式 -------------------------------------------
// safeProp :: Key -> {Key: a} -> Maybe a
const safeProp = curry((key, obj) => Maybe.of(obj?.[key]));
// safeHead :: [a] -> Maybe a
const safeHead = safeProp(0);
現在我們試著用 .map 把這三步串連起來:
// --- 取第一個 address 的 street ------------------------------
// firstAddressStreet :: User -> Maybe (Maybe (Maybe Street))
const firstAddressStreet = compose(
map(map(safeProp('street'))), // [3] Maybe(Maybe(Address)) -> Maybe(Maybe(Maybe(Street)))
map(safeHead), // [2] Maybe([Address]) -> Maybe(Maybe(Address))
safeProp('addresses') // [1] User -> Maybe([Address])
);
然後傳入我們的 user 資料:
const userNoAddresses = { name: 'Amy' };
const userWithStreet = {
name: 'John',
addresses: [{ street: { name: 'Mulburry', number: 8402 }, postcode: 'WC2N' }],
};
const nothingResult = firstAddressStreet(userNoAddresses);
const nestedResult = firstAddressStreet(userWithStreet);
console.log(nothingResult); // Nothing {}
console.log(nestedResult); // Maybe(Maybe(Maybe({name: 'Mulburry', number: 8402})))
可以看到當我們傳入 userWithStreet 時,得到的不是 Maybe(address),而是 Maybe(Maybe(Maybe(address)))。這就是我們所說的「巢狀的洋蔥」或「盒子裡的盒子」。
完整程式可參考此連結。
它破壞了我們一直以來努力維護的組合性 (Composition)。
我們無法繼續用 .map 來串接下一個操作。如果我們寫 nestedResult.map(getStreetNumber),getStreetNumber 這個函式會被應用在 Maybe(Maybe(address)) 上,而不是它期望的 address 物件上,這會導致非預期的結果或錯誤。我們的函數處理管道在此卡住了。
為了從 Maybe(Maybe(Maybe(address))) 中取出最終的街道資料,我們必須手動地、命令式地「拆箱」:先檢查外層的 Maybe 是 Just 還是 Nothing,如果是 Just,再取出裡面的 Maybe,再對它進行一次檢查... 這違背了我們使用 Maybe 來避免 if/else 巢狀地獄的初衷。
Functor 的 .map 是我們在 context 中(或說是容器中)進行組合的關鍵。它讓我們可以順暢地建立 pipe(f, g, h) 這樣的處理流程。然而,當流程中的某個函式(如 getAddress)本身就會創造一個新的 context 或容器時,.map 只是忠實地將這個新context 包進來,導致了 M(M(b)) 這種「阻塞物」。
我們需要一個能夠理解並處理這種「函數回傳容器」情況的工具,進而修復我們斷掉的組合鏈。
join為了解決這個「巢狀容器」問題,Monad 引入了一個函數:join。join 的功能非常單純:將任何兩層相同型別的容器壓平 (flatten) 成一層,以下是 join 的型別簽章。
join :: Monad m => m (m a) -> m a

圖 3 join 能將兩層相同型別的容器壓平為一層(資料來源: 自行繪製)
它的作用就像是從一個箱子裡,把內部的那個箱子拿出來,丟掉外層的箱子。讓我們看看 join 如何改善我們的程式碼:
const join = m => m.join();
const firstAddressStreet =
compose(
join, // Maybe(street)
map(safeProp('street')), // Maybe({...}) -> Maybe(Maybe(street))
join, // Maybe(head)
map(safeHead), // Maybe([...]) -> Maybe(Maybe(head))
safeProp('addresses') // obj -> Maybe(addresses)
);
const result = firstAddressStreet(userWithStreet); // Maybe({name: 'Mulburry', number: 8402})
在每個產生新 Maybe 的 .map 操作後加上 .join(),我們成功地將結構的深度控制在了一層。程式碼不再是可怕的巢狀 map,而是線性的鏈式呼叫。
而 Maybe 的 join 方法可以這樣定義,以下將現有的 Maybe 加上 join 方法:
// 這裡用 instanceof 判斷是否為 Maybe,但實務上可改用 _tag 來辨別型別
const isMaybe = (x) => x instanceof Just || x instanceof Nothing;
const Maybe = {
of: (value) =>
value === null || value === undefined ? new Nothing() : new Just(value)
};
class Just {
constructor(value) {
this.$value = value;
}
map(fn) {
return Maybe.of(fn(this.$value));
}
getOrElse(defaultValue) {
return this.$value;
}
toString() {
return `Just(${this.$value})`;
}
// 新增 join 方法
join() {
return isMaybe(this.$value) ? this.$value : this;
}
}
class Nothing {
map(fn) { return this; }
getOrElse(defaultValue) { return defaultValue; }
toString() { return 'Nothing()'; }
// 新增 join 方法
join() { return this; }
}
完整程式碼可參考此連結。
這種可以被「壓平」的能力,正是 Monad 之所以為 Monad 的關鍵特徵之一。現在,前言提到的定義就說得通了:
一個 Monad,就是一個可以被壓平的 Pointed Functor。(A Monad is a pointed functor that can flatten.)
另一個更常見的定義敘述是:
一個型別若同時提供
of與chain,並且滿足 Monad 的三條定律(結合律、左右單位律),那它就是 Monad。
chain雖然 join 解決了巢狀問題,但可能有人會注意到,map(f).join() 這種模式在程式碼中不斷重複出現,顯得有些累贅。既然這個模式如此常用,我們何不把它們打包成一個新的方法呢?
這就是 chain誕生的原因。chain = map + join。
// chain :: Monad m => (a -> m b) -> m a -> m b
const chain = curry((f, m) => m.map(f).join());
// 或者
// chain :: Monad m => (a -> m b) -> m a -> m b
const chain = f => compose(join, map(f));
chain 的其他稱呼例如:
>>=(稱為 bind)flatMap
chain
chain 將 map 和 join 這兩個步驟合併為一個操作。

圖 4 chain(f) 等價於 map(f) 加上 join(資料來源: 自行繪製)
現在我們用 chain 來重寫 firstAddressStreet:
const firstAddressStreet = compose(
chain(safeProp('street')), // head -> Maybe(street)
chain(safeHead), // addresses -> Maybe(head)
safeProp('addresses') // obj -> Maybe(addresses)
);
完整程式請見此連結。
chain 隱藏了 map 和 join 的細節,讓我們可以專注於組合我們的業務邏輯,而不用擔心容器的巢狀問題。
m.chain(f) 這種呼叫形式被稱為 infix (中綴) 或方法形式,因為 chain 寫在物件和函式之間。而在許多函式庫(如 Ramda)中,可能也會看到 prefix (前綴) 或函式形式,也就是將 chain 寫在最前面,作為一般函數來呼叫:
// Infix (方法形式)
Maybe.of(3).chain(x => Maybe.of(x + 1));
// Prefix (函式形式),資料(functor)置後
// chain(f, m)
chain(x => Maybe.of(x + 1), Maybe.of(3));
兩者在概念上是等價的,只是呼叫風格不同。
以下幾點回顧今天文章重點。
map 遇到的問題:巢狀容器當我們用 Functor 的 .map 處理一個會回傳容器(例如 a -> M(b))的函數時,會產生「盒子裡的盒子」的巢狀結構(M(M(b))),這會破壞函數組合的流暢性。
我們可用兩種工具來解決這個問題:
.join():一個簡單的「壓平」操作,能將兩層相同的容器扁平化為一層.chain():一個更方便的工具,它將 map 和 join 這兩個步驟合而為一。.chain(f) 相當於 .map(f).join()
Monad 可以理解為「一個可以被壓平的 Pointed Functor」,也等價於「實作了 of 與 chain 並遵守某些定律的型別」。Monad 讓我們可以將多個帶有 context 的計算串接成一個扁平、線性的處理流程,避免了手動拆箱和繁瑣的巢狀程式碼。
我們已經看到了 chain 如何解決巢狀問題,但要一個型別真正成為 Monad,它還需要滿足一些定律。這些定律確保了 chain 的行為是可預測的。在下一篇文章中,我們會再更瞭解 Monad 到底是什麼,以及它要遵循哪些定律。