到目前為止,我們介紹 Maybe Monad 其是專門處理無值情境以及 IO Monad 則是處理同步計算的 effect,例如 console.log
, localStorage
等等的,而這些操作基本上是不會失敗的。接下來今天來介紹專門處理錯誤情境的 Either Monad。
但在這之前先看看我們平常是如何處理錯誤情境的,
在 JS 的世界裡,常見處理錯誤的情境就是
if/else
: 針對情境切出各式各樣分支,在各分支內進行相對的邏輯處理throw/catch
: 根據不同的情境,當發生錯誤時丟出其錯誤,通常會搭配 try...catch...
舉例來說,現在要驗證使用者輸入的資料,其結構是
{
name: 'jing',
phone: '0916888888',
email: 'jing.open.to.opportunity@gmail.com'
}
而以下為驗證的函式
const isValidEmail = email => {
const re = /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
return re.test(email);
}
const isValidPhone = phone => /^09\d{2}-?\d{3}-?\d{3}$/.test(phone)
const isValidName = (name) => name.trim() !== '';
首先,先來看看如果是用 if/else
, 該如何處理
if/else
const runValidate = ({ name, email, phone }) => {
if (!isValidName(name)) {
return 'invalid name';
} else if (!isValidPhone(phone)) {
return 'invalid phone';
} else if (!isValidEmail(email)) {
return 'invalid email';
} else {
return { name, email, phone };
}
};
const result = runValidate({
name: '',
phone: '0916888888',
email: 'jing.open.to.opportunity@gmail.com'
}) // "invalid name"
if(typeof result === 'string'){
console.error(result);
}
那再來看看 throw/catch
的版本
throw/catch
const runValidate = ({ name, email, phone }) => {
if (!isValidName(name)) {
throw new Error('invalid name');
} else if (!isValidPhone(phone)) {
throw new Error('invalid phone');
} else if (!isValidEmail(email)) {
throw new Error('invalid email');
} else {
return { name, email, phone };
}
};
try {
runValidate({
name: '',
phone: '0916888888',
email: 'jing.open.to.opportunity@gmail.com',
});
} catch (e) {
console.error(e); // "invalid name"
}
可以看到無論是 if/else
或是 throw/catch
其處理都沒有辦法快速地知道這段程式到底在幹嘛。
或許有人覺得 throw/catch
不錯,但 throw/catch
並不是純函式,它不會 回傳(return) 錯誤而是 丟出(throw) 錯誤。還記得 pure function 那章提到的嗎,追求純函式其目的就是要達到 referential transparency,顯然 throw/catch
並不符合。
那廢話不多說開始介紹 Either Monad
Constructor
Either :: a -> Either c a
首先 Either 就跟 Maybe 一樣,皆由兩個 Type 所組合起來的,分別為 Either.Right
以及 Either.Left
,可以把 Either.Right
想像成 happy path, 而 Either.Left
想像成 sad path.
// Either.Right :: a -> Either c a
const Right = (x) => ({
x,
inspect: () => `Right(${x})`,
});
// Either.Left :: c -> Either c a
const Left = (x) => ({
x,
inspect: () => `Left(${x})`,
});
如上圖,Either Functor 就是將包覆在 Right 這個容器的值與 pure function 進行 compose,而運算的過程若有錯誤發生,就會切到 Left (sad path),在 Left 的 map
method 不會有任何作用,只將錯誤傳遞下去。
implement
const Right = (x) => ({
...
map: (f) => Right(f(x)),
...
});
const Left = (x) => ({
...
map: (_) => Left(x),
...
});
example
Right(10)
.map(R.add(1))
.map(R.add(2))
.inspect() // Right(13)
Left(10)
.map(R.add(1))
.map(R.add(2))
.inspect() // Left(10)
在第一個例子由於都是走 happy path 其就會正常的 compose 下去,相較於第二個例子,由於一開始的值就已經是在 Left 內,無論有多少個轉換函式都 Left 都會無視。
之後可以看到 Right 其實就是 Identity,與 Left 這個錯誤處理的 types 結合就是 Either Monad
由上圖可以看到,chain 就是將不同的鐵軌 (Right) 組合起來,而再組合的過程中也有可能會發生錯誤,所以又切分出其 sad path
implement
const Right = (x) => ({
...
chain: (f) => f(x),
...
});
const Left = (x) => ({
...
chain: (_) => Left(x),
...
});
example
const addOne = num => Right(R.add(1));
const error = () => Left(new Error('error'))
Right(10)
.chain(addOne)
.map(R.add(2))
.inspect() // Right(13)
Right(10)
.chain(error)
.map(R.add(2))
.inspect() // Left(Error: error)
可以看到第二個範例,中途如果突然出現了 error,軌道就會切到 Left 端,並且不會在對之後的函式進行 compose。
implement
const Right = (x) => ({
...
isRight: true,
ap: function (other) {
return other.isLeft ? other : this.map(other.x);
},
...
});
const Left = (x) => ({
...
isLeft: true,
ap: (_) => Left(x),
...
});
const lift2 = R.curry((g, f1, f2) => f2.ap(f1.map(g)))
example
lift2(R.add, Right(1), Right(1)); // Right(2)
lift2(R.add, Left('error'), Right(1)); // Left('error')
而 Applicative 的概念也是跟之前一樣,就不在贅述,只是這裡值得注意的是,我們需要新增 isLeft
以及 isRight
,原因就是如果像之前一樣實作 fantasy-land 對於 apply 的定義,就會導致下面的程式噴錯,所以需要用 isLeft
以及 isRight
輔助實作 ap
時的邏輯。
const Right = (x) => ({
...
ap: function (other) {
return this.map(other.x);
},
});
lift2(R.add, Left('error'), Right(1)); // 噴錯
fold & final code
而 Either 將值取出的方式就類似 pattern match,
implement
/**
* Happy Path
*/
const Right = (x) => ({
x,
isRight: true,
map: (f) => Right(f(x)),
chain: (f) => f(x),
ap: function (other) {
return other.isLeft ? other : this.map(other.x);
},
fold: (_, g) => g(x),
inspect: () => `Right(${x})`,
});
/**
* Sad Path
*/
const Left = (x) => ({
x,
isLeft: true,
map: (_) => Left(x),
chain: (_) => Left(x),
ap: (_) => Left(x),
fold: (f, _) => f(x),
inspect: () => `Left(${x})`,
});
const Either = {
Left,
Right,
of: Right,
};
export default Either;
example
Right(10)
.map(R.add(1))
.map(R.add(2))
.fold(console.error, console.log) // Right(13)
到這裡就簡單的介紹完最基本的 Either Monad,最後來改寫一下上面的範例
const isValidEmail = d => {
const re = /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
return re.test(d.email) ? Right(d) : Left('invalid email');
}
const isValidPhone = d => /^09\d{2}-?\d{3}-?\d{3}$/.test(d.phone) ? Right(d) : Left('invalid phone')
const isValidName = (d) => d.name.trim() !== '' ? Right(d) : Left('invalid name');
isValidEmail({
name: '',
phone: '0916888888',
email: 'jing.open.to.opportunity@gmail.com',
})
.chain(isValidPhone)
.chain(isValidName)
.fold(console.error, console.log); // invalid name
大功告成,但這樣似乎還有一個缺點,這樣變得我們的驗證函式會非常沒有彈性,因為每次都要放回全部的資料,如果今天格式改變了(ex: name
-> username
) 那這函式就沒有用了,之後有機會會提到如何處理這種情境,目前留給各位讀者思考一下,另外讀者們可以試著實作 point-free 版本的 Either,下一章討論!
各位弟兄們又有新的小表出現拉,這裡統整目前提到的 Monad,感謝大家閱讀!
ADT | Effect |
---|---|
Maybe | 處理無值情境 |
IO | 處理永不失敗的同步計算 |
Either | 處理錯誤情境 |
NEXT: Task Monad