【neverthrow】safeTryを理解する 〜Scalaのfor内包表記を添えて〜

2024/12/03に公開

この記事は弥生 Advent Calendar 2024シリーズ2の3日目の記事です。

はじめに

私が所属しているチームでは、フロントエンド・バックエンドともにTypeScriptで開発を行っています。

TypeScriptにおいて、型安全なエラーハンドリングを行いたい場合にneverthrowが便利です。

neverthrowを使うと、関数が返す値は「成功」と「エラー」いずれかを返すResult型になります。

https://github.com/supermacro/neverthrow

Result型としてラップされるため、値を使いたい場所では明示的に取り出す、またはResult型返す関数を andThen で繋ぎ合わせて関数内で取り回す必要があります。

このResult型を返す複数の関数をチェイン・ネストさせながら複雑な処理を書こうとすると、型パズルの泥沼にハマってしまいます。

safeTry を使うことでラフに書けるようになるのですが、独特な構文かつWeb上の解説記事が少ないため、手が出しづらい印象です。

typescript
// サンプルコードはneverthrowのREADMEより拝借
function myFunc(): Result<number, string> {
  return safeTry<number, string>(function*() {
    return ok(
      (yield* mayFail1()
        .mapErr(e => `aborted by an error from 1st function, ${e}`))
      +
      (yield* mayFail2()
        .mapErr(e => `aborted by an error from 2nd function, ${e}`))
    )
  })
}

この記事では、関数型言語として知られるScalaの構文と見比べながら safeTry への理解を深めてゆきます。

寄り道:ScalaのネストしたflatMap

早速ですが、少しScalaに寄り道し、次のような実装を行ってみます。

  • UserのPostに対してCommentがぶら下がっているデータがある
  • UserごとにPost数とComment数を集計する

Scalaにおける型定義と各種Listを返す関数は以下の通りです。

scala
// 型定義
case class User(id: Int, name: String)
case class Post(id: Int, userId: Int, content: String)
case class Comment(id: Int, postId: Int, content: String)
case class UserAggregate(id: Int, name: String, postCount: Int, commentCount: Int)

// 各種Listを返す関数
def getUsers: Either[String, List[User]] =
  Right(
    List(
      User(1, "Alice"),
      User(2, "Bob"),
      User(3, "Charlie")
    )
  )
def getPosts: Either[String, List[Post]] =
  Right(
    List(
      Post(11, 1, "Alice\'s post"),
      Post(12, 1, "Alice\'s another post"),
      Post(13, 2, "Bob\'s post")
    )
  )
def getComments: Either[String, List[Comment]] =
  Right(
    List(
      Comment(101, 11, "Alice\'s comment"),
      Comment(102, 12, "Alice\'s another comment"),
      Comment(103, 13, "Bob\'s comment"),
      Comment(104, 13, "Bob\'s another comment")
    )
  )

静的なListを返すだけなので本来Eitherを使う必要はないのですが、後ほどneverthrowのコードと比較するために便宜的にラップしています。

Scalaを書いたことのない方のために簡単に補足

TypeScriptを書いている方であれば、書き方が似ているのでなんとなく雰囲気で読めるかもしれませんが、念の為補足しておきます。

  • case class User(id: Int, name: String) の部分では「Int型の id と String型の name をもつ User 型」を定義
    • ちなみにScalaではTypeScriptと同様に構造的部分型が採用されています
  • def getUsers: Either[String, List[User]] の部分では「Rightに List[User] Leftに String をもつEither型を返す getUsers 関数」を定義
    • Eitherは成功または失敗する場合に使われる構造で、成功は「Right」失敗は「Left」として扱われます
    • 厳密には異なりますが、neverthrowのResult型と同じようなものだと思ってください
    • 関数の戻り値の型注釈もTypeScriptと同じ形式です
  • 関数の中身はRightとして静的なListを返す実装

flatMapを使い「UserごとにPost数とComment数を集計する」関数を書くと、次のような実装になります。

scala
def aggregate: Either[String, List[UserAggregate]] = {
  getUsers.flatMap { users =>
    getPosts.flatMap { posts =>
      getComments.map { comments =>
        users.map { user =>
          val userPosts = posts.filter(_.userId == user.id)
          val postCount = userPosts.length
          val commentCount = userPosts.foldLeft(0) { (acc, post) =>
            acc + comments.count(_.postId == post.id)
          }
          UserAggregate(user.id, user.name, postCount, commentCount)
        }
      }
    }
  }
}

読みづらいですね。

最も内側にある comment のmap内で使いたい user.idpost.id は、より外側のflatMapから参照する必要があります。
flatMapを直列にしてしまうとこれらの値にアクセスできなくなってしまうため、ネストしたflatMapができてしまいました。

flatMapについて

flatMapとは、mapしてflattenする処理をまとめて実行できる関数です。

mapをネストすると

List(
  List("hoge", "fuga"),
  List("piyo")
)

のように「ListのList」が返ってきてしまいます。

これはflattenによって平坦化することで解決できます。

List("hoge", "fuga", "piyo")

flatMapはこれらの処理をまとめて行うことができます。

ちなみに、flatMapは常にListを返すことが保証されているため、flatMapをチェインする・ネストするにかかわらず、Listを受け渡しする同じ挙動のプログラムを組むことができます。

寄り道:Scalaのfor内包表記

このネストしたflatMapの読みづらさを解消するのが、for内包表記です。

先のコードは、次のように書き換えることができます。

scala
def aggregate: Either[String, List[UserAggregate]] = {
  for {
    users <- getUsers
    posts <- getPosts
    comments <- getComments
  } yield {
    users.map { user =>
      val userPosts = posts.filter(_.userId == user.id)
      val postCount = userPosts.length
      val commentCount = userPosts.foldLeft(0) { (acc, post) =>
        acc + comments.count(_.postId == post.id)
      }
      UserAggregate(user.id, user.name, postCount, commentCount)
    }
  }
}

for のブロックの中で関数の実行結果を値として受け取り、 yield のブロックの中ではそれらの値を使った手続き的なコードが直感的に書けるのが、この記法の良いところです。

ネストしたflatMap・for内包表記 共に関数が返す型は同じ Either[String, List[UserAggregate]] となっており、実行結果も同じになります。

scala
aggregate match {
  case Right(data) => println(s"Result: $data")
  case Left(error) => println(s"Failed with error: $error")
}

// Result:
// List(
//   CountResult(1, "Alice", 2, 2),
//   CountResult(2, "Bob", 1, 2),
//   CountResult(3, "Charlie", 0, 0)
// )

andThen から safeTry への書き換え

話を本筋へ戻します。

TypeScriptでneverthrowを使い、先ほどと同様の要件の実装をしてみます。

型定義と各種Listを返す関数は以下の通りです。

typescript
type User = { id: number, name: string }
type Post = { id: number, userId: number, title: string }
type Comment = { id: number, postId: number, content: string }
type UserAggregate = {
  postCount: number,
  commentCount: number
} & User;

const getUsers =
  (): Result<User[], Error> => {
    return fromThrowable(
      () => ([
        { id: 1, name: 'Alice' },
        { id: 2, name: 'Bob' },
        { id: 3, name: 'Charlie' }
      ]),
      () => new Error('Failed to fetch users')
    )()
  }

const getPosts =
  (): Result<Post[], Error> => {
    return fromThrowable(
      () => ([
        { id: 11, userId: 1, title: 'Alice\'s post' },
        { id: 12, userId: 1, title: 'Alice\'s another post' },
        { id: 13, userId: 2, title: 'Bob\'s post' }
      ]),
      () => new Error('Failed to fetch posts')
    )()
  }

const getComments =
  (): Result<Comment[], Error> => {
    return fromThrowable(
      () => ([
        { id: 101, postId: 11, content: 'Alice\'s comment' },
        { id: 102, postId: 12, content: 'Alice\'s another comment' },
        { id: 103, postId: 13, content: 'Bob\'s comment' },
        { id: 104, postId: 13, content: 'Bob\'s another comment' }
      ]),
      () => new Error('Failed to fetch comments')
    )()
  }

こちらも静的なListを返すだけですが、Result型を利用するために便宜的に fromThrowable を利用しています。

これらの関数を andThen で繋ぎあわせ、集計処理を書いてみます。

typescript
const aggregate =
  (): Result<UserAggregate[], Error> => {
    return getUsers().andThen(
      users => {
        return getPosts().andThen(
          posts => {
            return getComments().map(
              comments => {
                return users.map(
                  user => {
                    const postCount = posts.filter(
                      post => post.userId === user.id
                    ).length
                    const commentCount = comments.filter(
                      comment => {
                        return posts.find(
                          post => post.userId === user.id && post.id === comment.postId
                        )
                      }
                    ).length
                    return { ...user, postCount, commentCount }
                  }
                )
              }
            )
          }
        )
      }
    )
  }

ScalaのネストしたflatMapと同様、読むのがつらいですね。

safeTry で書き換えると、次のようになります。

typescript
const aggregate =
  (): Result<UserAggregate[], Error> => {
    return safeTry(function*() {
      const users = yield* getUsers()
      const posts = yield* getPosts()
      const comments = yield* getComments()
      return ok(users.map(
        user => {
          const postCount = posts.filter(
            post => post.userId === user.id
          ).length
          const commentCount = comments.filter(
            comment => {
              return posts.find(
                post => post.userId === user.id && post.id === comment.postId
              )
            }
          ).length
          return { ...user, postCount, commentCount }
        }
      ))}
    )
  }

Scalaのfor内包表記と似た構造になっていることがお分かりいただけるかと思います。

ジェネレータ関数内の yield* で実行結果を値として受け取り、それらの値を使った手続き的なコードを書くことができています。そして、ネストが浅くなっています。

ネストしたandThen・safeTry 共に関数が返す型は同じ Result<UserAggregate[], Error> となっており、実行結果も同じになります。

typescript
aggregate()._unsafeUnwrap()

// [
//   { id: 1, name: 'Alice', postCount: 2, commentCount: 2 },
//   { id: 2, name: 'Bob', postCount: 1, commentCount: 2 },
//   { id: 3, name: 'Charlie', postCount: 0, commentCount: 0 }
// ]

safeTry を使うことで、型安全性を維持したままResult型の関数を複数組み合わせた処理が書きやすくなりました。

safeTryのジェネレータ関数

andThen をネストさせるよりはスッキリ書けるとはいえ、safeTry の記法は独特です。

function*yield* を利用するジェネレータ関数が出てきます。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Statements/function*

ジェネレータ関数について詳しくはmdnに解説されているとおりですが、ひとことで説明すると「処理を途中で停止・再開しながら値を逐次生成する関数」です。

function* 構文で定義し、yield で値を返します。yield は単に値を返す場合に使い、yield* は他のジェネレータや反復可能オブジェクト(配列等)の値をまとめて扱う場合に使います。

mdnの例を拝借して解説します。

javascript
function* anotherGenerator(i) {
  yield i + 1;
  yield i + 2;
  yield i + 3;
}

function* generator(i) {
  yield i;
  yield* anotherGenerator(i);
  yield i + 10;
}

const gen = generator(10);

console.log(gen.next().value); // 10
console.log(gen.next().value); // 11
console.log(gen.next().value); // 12
console.log(gen.next().value); // 13
console.log(gen.next().value); // 20

anotherGenerator関数は、引数 i を受け取り、yield を使って3つの値を順次生成します。
例えば、anotherGenerator(10) であれば、11, 12, 13を生成します。

generator関数は、yield を使ってまず引数 i をそのまま返します。
次に yield* anotherGenerator(i) で、anotherGeneratorから生成される値を順次返します。
最後に yield i + 10 で、計算された値を生成します。

gen.next() で1つずつ値を取り出してゆく処理を順に追うと、以下のようになります。

  1. gen.next().value: yield i の部分が実行され、10を返します。
  2. gen.next().value: yield* anotherGenerator(i) の最初の値、つまり anotherGenerator(10) の最初のyield値である11を返します。
  3. gen.next().value: 次のanotherGeneratorの値、つまり12を返します。
  4. gen.next().value: 次のanotherGeneratorの値、つまり13を返します。
  5. gen.next().value: anotherGeneratorが終了し、generator関数に戻り、yield i + 10 が実行されて20を返します。

ジェネレータ関数の挙動がざっくり理解できたところで、改めてsafeTryのコードを眺めてみましょう。

typescript
const aggregate =
  (): Result<UserAggregate[], Error> => {
    return safeTry(function*() {
      const users = yield* getUsers()
      const posts = yield* getPosts()
      const comments = yield* getComments()
      return ok(
        // 省略
      )}
    )
  }

ジェネレータ関数は「処理を途中で停止・再開しながら値を逐次生成する関数」であり、yield* の各ステップでResult型の値を評価します。
各ステップが ok の値であれば処理が続行され、 err の値であればジェネレータ関数を中断し、そのエラーがsafeTryに伝播します。
この仕組みによって、手続き的に処理を書き下しながらもneverthrowの型安全なエラー処理がそのまま使えるというわけです。

とはいえ、「ジェネレータ関数なんて使わずに、ライブラリ側でもっと簡潔な記法を提供してよ」という気持ちにもなってしまいますが safeTry関連のPull Request によると、利用者にジェネレータ関数を書かせる仕様としたのは渋々の選択だったようです(もっとシンプルな記法も検討したが難しかったと書かれています)

おわりに

Scalaのfor内包表記と比較しながら、neverthrowにおける safeTry の書き方について解説してみました。
また、ジェネレータ関数の挙動についても触れました。

厳密にはfor内包表記はmap/flatMapの糖衣構文であり safeTry とは別物です。
あくまで「こういった書き換えの場面で使えそう」という感触を掴むための一例として捉えていただければと思っています。

safeTry を使うことで、Result型の世界の中で手続き的な処理が書きやすくなります。
構文こそ独特ですが、慣れてしまえば便利な道具なのでぜひ使ってみてください。

良いneverthrowライフを!

Discussion