【neverthrow】safeTryを理解する 〜Scalaのfor内包表記を添えて〜
この記事は弥生 Advent Calendar 2024シリーズ2の3日目の記事です。
はじめに
私が所属しているチームでは、フロントエンド・バックエンドともにTypeScriptで開発を行っています。
TypeScriptにおいて、型安全なエラーハンドリングを行いたい場合にneverthrowが便利です。
neverthrowを使うと、関数が返す値は「成功」と「エラー」いずれかを返すResult型になります。
Result型としてラップされるため、値を使いたい場所では明示的に取り出す、またはResult型返す関数を andThen で繋ぎ合わせて関数内で取り回す必要があります。
このResult型を返す複数の関数をチェイン・ネストさせながら複雑な処理を書こうとすると、型パズルの泥沼にハマってしまいます。
safeTry を使うことでラフに書けるようになるのですが、独特な構文かつWeb上の解説記事が少ないため、手が出しづらい印象です。
// サンプルコードは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を返す関数は以下の通りです。
// 型定義
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数を集計する」関数を書くと、次のような実装になります。
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.id
や post.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内包表記です。
先のコードは、次のように書き換えることができます。
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]]
となっており、実行結果も同じになります。
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を返す関数は以下の通りです。
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 で繋ぎあわせ、集計処理を書いてみます。
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 で書き換えると、次のようになります。
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>
となっており、実行結果も同じになります。
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*
を利用するジェネレータ関数が出てきます。
ジェネレータ関数について詳しくはmdnに解説されているとおりですが、ひとことで説明すると「処理を途中で停止・再開しながら値を逐次生成する関数」です。
function*
構文で定義し、yield
で値を返します。yield
は単に値を返す場合に使い、yield*
は他のジェネレータや反復可能オブジェクト(配列等)の値をまとめて扱う場合に使います。
mdnの例を拝借して解説します。
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つずつ値を取り出してゆく処理を順に追うと、以下のようになります。
-
gen.next().value
:yield i
の部分が実行され、10を返します。 -
gen.next().value
:yield* anotherGenerator(i)
の最初の値、つまりanotherGenerator(10)
の最初のyield値である11を返します。 -
gen.next().value
: 次のanotherGeneratorの値、つまり12を返します。 -
gen.next().value
: 次のanotherGeneratorの値、つまり13を返します。 -
gen.next().value
: anotherGeneratorが終了し、generator関数に戻り、yield i + 10
が実行されて20を返します。
ジェネレータ関数の挙動がざっくり理解できたところで、改めてsafeTryのコードを眺めてみましょう。
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