neverthrowのsafeTry解体新書
前回の記事では、safeTry
の概要と解決している課題についてお話しました。今回は、さらに掘り下げてsafeTry
の内部実装を見ていきたいと思います。
safeTryのおさらい
前回使ったコード例を再掲します。
function myFunc(): Result<number, string> {
return safeTry(function*() {
const value1 = yield* mayFail1()
.mapErr(e => `aborted by an error from 1st function, ${e}`)
.safeUnwrap()
const value2 = yield* mayFail2()
.mapErr(e => `aborted by an error from 2nd function, ${e}`)
.safeUnwrap()
return ok(value1 + value2)
})
}
yield*
を使っているResultのどれがErr
を返せばsafeTry
自体の戻り値もそのErr
となり、そうでなければreturnに渡したokの値が返るのでした。つまり、この中でthrow
を使ったときのようなある種の大域脱出が発生しうるということです。しかし、throw
とは異なり関数の戻り値として返ってくるので型がつきます。不思議ですね。
さて、前回の記事では意図的に無視していましたが、一見して目を引く要素がいくつかあります。
-
safeTry
の引数がgenerator関数 -
yield*
の戻り値を使っている - Resultの
safeUnwrap
メソッドを呼び出している
これらを手掛かりに、safeTry
の内部実装を読み解いていくことにします。
内部実装
safeTryとsafeUnwrapの定義
safeTry
の関数本体の定義はたったこれだけです。
Promiseのときの処理を無視すれば、generatorの最初の結果を返すだけです。実際の挙動から推測するに、どこかでErr
が返されれば最初の結果がそのErr
になるし、そうでなければ最後にreturnした値がそうなるのでしょう。
次にsafeUnwrap
について見てみましょう。ResultAsyncについては無視しますがほぼ同じです。
順番にOk
とErr
の定義です。これらも非常にシンプルで、枝葉を切り落とせばそれぞれ成功と失敗の値をgeneratorで包んで返しているだけです。しかし、重要なのは Ok
ではgenerator関数の中でreturn
を、他方でErr
ではyield
を使っている点です。これが大域脱出の肝となるポイントなので、generatorの挙動をおさらいしながら理解を深めていきましょう。
[Symbol.iterator]じゃないの?
iterator/generatorをある程度理解していると、「なぜ[Symbol.iterator]
ではなくsafeUnwrap
という名前付きのメソッドを定義しているのか」と疑問に思われる方もいるかもしれません。
筆者も最初はそう思ったのですが、[Symbol.iterator]
を定義してしまうと、テストで面倒が発生します。JestやVitestのmatcherで[Symbol.iterator]
を持つオブジェクトを比較すると、maximum call stack size exceeded
が発生することがあります。これはもしかしたらmatcher側の実装の問題かもしれませんが、ひとまずこの問題がある以上は名前付きメソッドにする利点の方が勝るかと思われます。というのも、もちろんok(1).value
のような形で値を取り出して比較すれば回避も可能なのですが、expect(ok(1)).toEqual(ok(1))
のようにそのまま比較できた方が便利だからです。
generatorの基本動作
iterator/generatorの基礎的な[1]理解については、Masaki Haraさんの記事に詳しいので、そちらをご参照下さい。本記事では実際の挙動をベースにかいつまんで説明します。
次のようなgenerator関数があったとします。
function* gen1() {
yield 1
yield 2
}
gen1
の結果を走査する方法の1つは、next
メソッドです。
const iter = gen1()
console.log(iter.next()) // { value: 1, done: false }
console.log(iter.next()) // { value: 2, done: false }
console.log(iter.next()) // { value: undefined, done: true }
next
メソッドは、{ value: 1, done: false }
のようなオブジェクトを返します。value
がyield
で返された値、done
がgeneratorが終了したかどうかを表します。今回はyield
が2つしかないので、3回目のnext
でdone
がtrue
、value
は存在しないのでundefined
になります。
それでは、gen1
の中でreturn
を使うとどうなるのでしょうか?
function* gen1() {
yield 1
return 2
}
const iter = gen1()
console.log(iter.next()) // { value: 1, done: false }
console.log(iter.next()) // { value: 2, done: true }
2回目のnext
がdone: true
になりました。先ほどの例では、3回目のnext
呼び出しで走査の終了がわかったのに対し、return
を使うと終了を明示できるので2回で済みました。
このreturn
の挙動が一番重要なポイントとなるのでよく覚えておいてください。続いて、yield*
について見ていきましょう。
yield*
safeTry
の引数に渡されるgenerator関数の中では、yield*
演算子が使われていました。yield*
は現在のgeneratorの走査をgenerator/iteratorに移譲します。「何を言っているんだ」という感じですが、具体例を見てみましょう。
function* gen1() {
yield 1
yield 2
}
function* gen2() {
yield* gen1()
yield 3
yield 4
}
gen2
は実のところ、以下のように定義したのと同じものです。
function* gen2() {
for (const value of gen1()) {
yield value
}
yield 3
yield 4
}
例えば、for (const value of gen2())
でgen2
の結果を走査していくと、最初の2ループではgen1
の結果が返ってきます。確かに移譲している感じがします。
これがyield*
の基本的な使い方なのですが、safeTry
の中ではその戻り値が使われています。const value1 = yield* mayFail1() ...
の部分です。そうです、いかにも文っぽい見た目をしていますが、 yield*
は戻り値を持つのです。MDNによると、戻り値になるのは以下のようなものです。
Returns the value returned by that iterator when it's closed (when done is true).
when done is true
は、先ほどみたようなnext
メソッドの結果でdone
がtrue
になった場合です。具体例を見ましょう。
function* gen1() {
yield 1
return 2
}
function* gen2() {
const v = yield* gen1()
console.log(v)
return v + 1
}
const iter = gen2()
console.log(iter.next())
console.log(iter.next())
// { value: 1, done: false }
// 2
// { value: 3, done: true }
gen2
で生成されたgeneratorの走査では、移譲されたgen1
の走査が実行されます。gen1
はreturn
を持っているので、yield*
の仕様通りにこれがv
に代入されたというわけです。また、 gen1
がreturn
した値がそのままgen2
の走査結果となるわけではないことも重要です。gen2
は1
とv + 1
しか返していません。
まとめ
ここで、Ok
がreturn
を使っていた意味がわかります。Ok
だけがreturn
を使うことによって、const value1 = yield* mayFail1() ...
のような形で、成功の値だけを変数に割り当てることが可能になります。また、safeTry
に渡すgenerator関数内の全てのsafeUnwrap
がOk
だった場合は、そのgenerator関数の最初の結果は最後のreturn
の値になるのであって、各safeUnwrap
のreturn
値ではありません。
他方、Err
の場合はyield
を使っているので、Err
のsafeUnwrap
が呼ばれた時点でgenerator関数の最初の結果はそのErr
となるのです。
おわりに
以上、safeTry
の内部実装の解説でした。ほんの数行の定義で賢く実装されているのがお分かりいただけたでしょうか。詳しく読んではいませんが、おそらくEffectも似たような仕組みを使っているのではないかと思われます。
前回からの繰り返しになりますが、筆者は「とりあえずsafeTry
使っとけ」と考えるぐらいにはこの子がお気に入りです。メソッドチェーンが増えたり関数型っぽい見た目になりすぎるのを忌避していた方も、これを機にまた一度試してみてはいかがでしょうか。
-
ここで言う「基礎」とは「簡単」を意味しません。なかなか骨のある内容ですが勉強になります。 ↩︎
Discussion