- Result型を使いたいが、世間との折り合いが難しい
- 自前のコードもぱっと見あまり難しくしたくない
と思うのです。で、自前で実装するとどうなるかな?ってことでやってみました:
class Result<T, E> {
kind: "Ok" | "Err"
value: T | E
constructor(kind: "Ok" | "Err", value: T | E) {
this.kind = kind
this.value = value
}
static wrap<T, E>
(f: () => T)
: Result<T, E> {
try {
return this.result( f() )
} catch (error) {
return this.err(error)
}
}
static wrapWith<T, E, U>
(f: (value: T) => U)
: (value: T) => Result<U, E> {
return (value: T) => {
try {
return this.result( f(value) )
} catch (error) {
return this.err(error)
}
}
}
static result<T>
(value: T)
: Result<T, E> {
return value == undefined
? this.err("null or undefined")
: Number.isNaN(value)
? this.err("NaN")
: value === Infinity || value === -Infinity
? this.err("+/-Infinity")
: this.ok(value)
}
static ok<T>
(value: T)
: Result<T, E> {
return new Result("Ok", value)
}
static err<E>
(error: E)
: Result<T, E> {
return new Result("Err", error)
}
flatMap<U>
(f: (value: T) => Result<U, E>)
: Result<U, E> {
if (this.kind === "Ok") {
return f(this.value);
} else {
return Result.err(this.value)
}
}
into<U>
(f: (value: T) => U)
: Result<U, E> {
return this.flatMap( Result.wrapWith(f) )
}
matchWith<T, E>
(cases: { Ok: (value: T) => any
, Err: (error: E) => any
}
)
: any {
switch (this.kind) {
case "Ok":
return cases.Ok(this.value)
case "Err":
return cases.Err(this.value)
}
}
}
// 使用例:
Result.result(0) // 値をResult型にする
.into(x => x / 0)
.into(x => x + 1)
.matchWith(
{Ok: n => console.log("Ok", n)
, Err: e => console.error("Error", e)
}
) // 'Error' 'NaN'
Result.wrap(() => 1 / 0) // いきなり失敗するかもしれない計算をしてResult型に包む
.into(x => x + 1)
.matchWith(
{Ok: n => console.log("Ok", n)
, Err: e => console.error("Error", e)
}
) // 'Error' '+/-Infinity'
異常値は result で、計算の失敗は wrap/wrapWith ではねるようにしてます。
こうすることで、Result型を返す関数について考えなくてよくなり、失敗するかもしれない計算を単純にパイプラインすることができます。最後にOk/Errの時に実行する関数のオブジェクトとマッチさせます。
たいがいはこれでok。
エラーを独自に作り込みたい場合は:
- エラー条件時に undefined を返す関数を作って into メソッドに与える
- 一番簡単かつ型安全に書ける
- エラーメッセージは工夫できない。なにせ undefined のものなので...
const validNumber =
(f: (n: number) => boolean) =>
(n: number) => {
if( f(n) ) return n
return undefined
}
Result.result("あああああああああ")
.into(s => s.length)
.into(validNumber(n => n >= 10))
.into(validNumber(n => n <= 500))
.matchWith(
{Ok: n => console.log("Ok", n)
, Err: e => console.error("Error", e)
}
) // 'Error' 'null or undefined'
- エラーを投げる関数を作って intoメソッドに与える
- 従来の手法で書ける
- throw使ったら負け? いや気持的に...
const validNumber =
(f: (n: number) => boolean) =>
(n: number) => {
if( f(n) ) return n
throw new Error("Validation not passed")
}
Result.result("あああああああああ")
.into(s => s.length)
.into(validNumber(n => n >= 10))
.into(validNumber(n => n <= 500))
.matchWith(
{Ok: n => console.log("Ok", n)
, Err: e => console.error("Error", e)
}
) // 'Error' Error: 'Validation not passed'
- Result型を返す関数を作って flatMap メソッドに与える
- 型安全に簡潔に記述できる
- 難しく見える、 かっこつけてるように見える...etc
const validNumber =
(f: (n: number) => boolean) =>
(n: number) => {
const r = Result.wrapWith(f)(n)
return r.kind === "Err"
? r
: r.value
? new Result("Ok", n)
: new Result("Err", "Validation not passed")
}
Result.result("あああああああああ")
.into(s => s.length)
.flatMap(validNumber(n => n >= 10))
.flatMap(validNumber(n => n <= 500))
.matchWith(
{Ok: n => console.log("Ok", n)
, Err: e => console.error("Error", e)
}
) // 'Error' 'Validation not passed'
これでどうでしょう?
と、ここまで書いてきたのは、自前のResultの文脈に、エラーを出しちゃったりする既存の関数を、ストレス少な目で取り込む方法でした。
その逆、つまり、従来の try/catch の文脈に Result をぶちこむ方法(unwrap)も 要るかなあと思いました。そのうち追記するかも。