🙅

neverthrowのsafeTry解体新書

2024/09/01に公開

前回の記事では、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の関数本体の定義はたったこれだけです。

https://github.com/supermacro/neverthrow/blob/fb294038c8509e3aba17cd74cc6f11766bfe2535/src/result.ts#L124-L134

Promiseのときの処理を無視すれば、generatorの最初の結果を返すだけです。実際の挙動から推測するに、どこかでErrが返されれば最初の結果がそのErrになるし、そうでなければ最後にreturnした値がそうなるのでしょう。

次にsafeUnwrapについて見てみましょう。ResultAsyncについては無視しますがほぼ同じです。

https://github.com/supermacro/neverthrow/blob/fb294038c8509e3aba17cd74cc6f11766bfe2535/src/result.ts#L370-L376

https://github.com/supermacro/neverthrow/blob/fb294038c8509e3aba17cd74cc6f11766bfe2535/src/result.ts#L453-L460

順番にOkErrの定義です。これらも非常にシンプルで、枝葉を切り落とせばそれぞれ成功と失敗の値を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 }のようなオブジェクトを返します。valueyieldで返された値、doneがgeneratorが終了したかどうかを表します。今回はyieldが2つしかないので、3回目のnextdonetruevalueは存在しないので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回目のnextdone: 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メソッドの結果でdonetrueになった場合です。具体例を見ましょう。

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の走査が実行されます。gen1returnを持っているので、yield*の仕様通りにこれがvに代入されたというわけです。また、 gen1returnした値がそのままgen2の走査結果となるわけではないことも重要です。gen21v + 1しか返していません。

まとめ

ここで、Okreturnを使っていた意味がわかります。Okだけがreturnを使うことによって、const value1 = yield* mayFail1() ...のような形で、成功の値だけを変数に割り当てることが可能になります。また、safeTryに渡すgenerator関数内の全てのsafeUnwrapOkだった場合は、そのgenerator関数の最初の結果は最後のreturnの値になるのであって、各safeUnwrapreturn値ではありません。

他方、Errの場合はyieldを使っているので、ErrsafeUnwrapが呼ばれた時点でgenerator関数の最初の結果はそのErrとなるのです。

おわりに

以上、safeTryの内部実装の解説でした。ほんの数行の定義で賢く実装されているのがお分かりいただけたでしょうか。詳しく読んではいませんが、おそらくEffectも似たような仕組みを使っているのではないかと思われます。

前回からの繰り返しになりますが、筆者は「とりあえずsafeTry使っとけ」と考えるぐらいにはこの子がお気に入りです。メソッドチェーンが増えたり関数型っぽい見た目になりすぎるのを忌避していた方も、これを機にまた一度試してみてはいかがでしょうか。

脚注
  1. ここで言う「基礎」とは「簡単」を意味しません。なかなか骨のある内容ですが勉強になります。 ↩︎

Discussion