プログラミング言語におけるエラー処理には、エラー値を返すもの、例外を送出するもの、多値を返すもの、Result型を返すもの様々あるが、個人的にはResultパターンが現状の最適解のように感じている。
JavaScriptは面白い言語で、エラーを報告するためにundefinedやnullといった未定義値を返すこともあれば、例外を送出することもある。未定義値はプロパティにアクセスした際に該当なしを示すために使われるが、それがundefinedになるのかnullになるのかは対象によって異なる。例外は未定義値を返すことすら適切でない場合や、コード上の不備、実行時のエラーによって発生する1。
Resultパターンを導入する
未定義値の取扱い
undefinedやnullが返却されたからといって、それがエラーであるかどうかは文脈による。それが期待された値の可能性もある。undefinedは該当なし、nullは値としては存在しても未定義であることを示しているが、状況にもよるのでこのふたつは混同しやすい。Optionパターンでの表現することを考えると、Some(null) はnullという値があるが2、None は値がないことを表しているので、未定義値にしても値に意味があるのかどうかを判断しやすい。
また、Nullableな値はしばしば問題を引き起こす。undefinedであればメソッド呼び出しは失敗するし、nullであれば成功することもあれば失敗することもある中途半端なことが起きる。最近では ?? や ?. 演算子が追加されたこともあり、Nullableな値を処理しやすくなったものの、やはり安全なコードを書くには速やかにnullを排除しなければならない。
通常、Nullableな値に遭遇したときにはなんらかの選択が必要で、別の値に置き換えるとか、そのデータは無視するとか、処理できないのでエラーとするとか、プログラマに選択を迫る設計にすることで、未定義値を曖昧にしない堅牢なコードが書けるはずである。しかし、標準ライブラリはNullableを扱うので、これをラップするインタフェースを用意することで、なるべく安全なJavaScriptにしようという試みがOptionパターンだ。
一例として parseInt() を考えてみる。この関数は例外を送出せず、整数値に変換できない場合にはNaNを返却する。NaNを返却することは正しいような気もするが、parseInt() の結果を使って計算が続行できることが都合が悪いときもあるし、きっと計算結果がNaNであることは検証されない。
よりよいアプローチは変換に失敗したときに既定値を指定できる parseIntOr() やnullを返す parseIntOrNull() を定義することだ。これによってプログラマに対して失敗したことの選択を迫ることができる。
function parseIntOr(value, radix, defaultValue) {
const result = parseInt(value, radix)
return Number.isNaN(result) ? defaultValue : result
}
function parseIntOrNull(value, radix) {
return parseIntOr(value, radix, null)
}この派生関数は標準のJavaScriptに準拠していて、名前的にもやることは明確だ。しかし、依然としてnullに対処しなければならない。ここでOptionパターンを使いnullを排除すれば、Optionという形で値があることを保証できるし、Some(x) と None のどちらの値になったとしても、値を取り出すには責任を持って対処しなければならなくなる。
function parseIntOpt(value, radix) {
const result = parseInt(value, radix)
return Number.isNaN(result) ? new None() : new Some(result)
}
const a = parseIntOpt('42')
console.log(a) // Some(42) このままでは値を取り出せない
console.log(a.isSome) // 値があることを確認する
console.log(a.unwrapOr(0)) // 失敗したら0にする
console.log(a.unwrap()) // 値を取り出すが、Noneだったら例外を送出
const { value } = a.unpack() // パターンマッチングの代わり
if (value !== undefined) {
}Noneに対して unwrap() を呼び出すと例外を送出するが、これはコード上の考慮不足であり、プログラマが対処しなければならないことだ。catchしてクライアント側にエラー内容を伝える必要はないし、それによってコードもスッキリする。
例外の取扱い
例外は大域脱出によって、関数呼出しの階層を飛び越えて制御を戻すことができる。この性質は便利な反面で課題もある。ある処理がどういう例外を送出するのかが不明瞭であるし、それを捕捉(try-catch)されているのかが分かりづらい。
MVCモデルを考えると、エラーが発生したことをViewに通知するためにあえて捕捉しないこともあるし、Modelで抑えるべきエラーならば、エラー発生時のプランBを提供するかもしれない。あるいは別の例外に詰め替えて新しい例外を送出することもある3。例外の課題は、それを処理する方法はあるものの、責任の所在が曖昧になるところだ。誰がどこでその例外を捕捉すべきなのか一貫していない。一番雑なやり方としてはアプリケーションがクラッシュすることを防ぐためにControllerで最上位の例外を対象にcatchすればいいのだが、それであっても依然エラーが発生することに対して責任は負いきれていない。
Result型はOption型同様に、成功と失敗のそれぞれのケースで値を返すことで、呼び出し側がその結果をどのように処理するべきかを選択させる。結果的に握りつぶすことになっても構わないが、大域脱出を許さずに必ず結果に向き合わせる。
標準のJavaScriptは未定義値を返すこともあれば例外も起こすこともあるので、これを適切にResult型に変換するためのインタフェースを用意する。これによって、未定義値は Ok(None) に、エラーは Err(e) に、成功は Ok(x) に変換される。
class Result {
static from(action) {
try {
const result = typeof action == 'function' ? action() : action
return result == null ? new Ok(new None()) : new Ok(result)
} catch (e) {
return new Err(e)
}
}
}
const a = Result.from(42)
const b = Result.from(() => 42)ユニット型
ここで考えているOption型とResult型はundefinedとnullを許容しないことを想定している。なので成功ケースでは new Ok(new None()) を返すことも考えられるが、この表現は冗長であるし、Noneに意味があるのかないのか曖昧である。解決策としては
window._ = Symbol.for('()')とすれば new Ok(_) と記述できる。lodashやUnderscore.jsとは衝突するし、グローバル空間を汚染するので、好みではなければ、
class Ok extends Result {
static get nil() {
return new Ok(Symbol.for('()'))
}
}
console.log(Ok.nil)としてもいい。
実際にどうか?
JavaScriptにおけるResultパターンもほかの言語同様に有効ではありそうなものの、実際にその機会に恵まれておらず試せていない。会社におけるチーム開発においては、個々の能力差もあるだろうし、概念の理解が難しいので、メンバーからの了承が得られにくいと考える。もし自分だけが保守するような小さなプロジェクトがあればそこで概念実証(Proof of Concept: PoC)を行って、その利点と欠点をチームに示さなければならないかもしれない。
少なからずPure JSからは遠ざかるし、Resultパターンのためのライブラリも必要となるので、その質やメンテナンス体制をいかに維持していくかという課題にも向き合わなければならない。