なぜResult型ライブラリを再発明したのか
はじめに
TypeScriptでエラーハンドリングを型安全に行いたいと考えたとき、皆さんはどのようなアプローチを取るでしょうか。
JavaScript/TypeScriptの標準的なエラーハンドリング手法であるtry/catch
は、型安全性に欠け、エラーが発生する可能性のあるコードを追跡するのが難しいという問題があります。
そんな課題を解決するために、よく用いられるのがResult型を用いたエラーハンドリングです。
Result型とは、成功時の値と失敗時のエラーを明示的に表現する型です。
TypeScriptにおけるResult型ライブラリといえば、neverthrowが最も有名で広く使われています。
また、最近ではEffectのような包括的なエコシステムも登場していますが、Result型だけを扱いたい場合には過剰と感じることもあります。
この記事では、私がneverthrow
を使う中で感じた限界と、それを乗り越えるために新しいResult型ライブラリbyethrowを開発した経緯、そしてそのコアデザインについて紹介します。
neverthrowを使って感じた限界
neverthrow
は非常に優れたライブラリで、多くのプロジェクトで採用されています。
私自身も実際のプロジェクトで導入し、長年にわたって愛用してきました。
しかし、使い込むうちにいくつかの設計上の制約に直面することもありました。
クラス設計の制約
neverthrow
のResultはOk/Err、ResultAsyncといった幾つかのクラスとして実装されています。
これにより、メソッドチェーンを利用した直感的な操作が可能ですが、独自の操作を追加したいときに大きな障壁となります。
// neverthrowのResultはクラスなので、独自メソッドを追加するには継承が必要
// しかし、既存メソッドがすべてデフォルトの`Ok`/`Err`を返すため、全てのメソッドをオーバーライドする必要がある
type MyResult<T, E> = MyOk<T, E> | MyErr<T, E>;
class MyOk<T, E> extends Ok<T, E> {
isOk(): this is MyOk<T, E> {
return super.isOk();
}
map<A>(f: (t: T) => A): MyResult<A, E> {
return new MyOk(f(this.value))
}
// 他のメソッドもすべてオーバーライドする必要がある
}
class MyErr<T, E> extends Err<T, E> {
// 同様にすべてのメソッドをオーバーライドする必要がある
}
このように、クラスベースの設計は拡張性に欠け、独自の振る舞いを追加するのが非常に困難です。
クラスではなく関数を別途定義することでこの問題を回避できますが、メソッドチェーンの利便性が失われてしまいます。
同期/非同期APIの分離
neverthrow
では、同期と非同期のResultに対して別々のAPIを用意しています。
import { ok, okAsync, Result, ResultAsync } from 'neverthrow';
// 同期の場合
const syncResult: Result<string, Error> = ok('value');
// 非同期の場合
const asyncResult: ResultAsync<string, Error> = okAsync('value');
// Resultをチェインさせる場合
const combined: ResultAsync<string, Error> = ok('value')
.andThen((value) => ok(value)) // 同期Result同士をチェインする場合はandThenを使う
.asyncAndThen((value) => okAsync(`${value} async`)); // 非同期Resultとチェインする場合はasyncAndThenを使う
ok
とokAsync
、Result
とResultAsync
といった区別が必要で、同期と非同期を自然に合成出来ません。
実際のアプリケーションでは、同期的な処理と非同期的な処理が混在することは珍しくないため、この分離は開発体験を損ねてしまいます。
メンテナンスの停滞
さらに、neverthrow
のGitHubリポジトリを見ると、issueやPRが長期間放置されている状況が見られます。
これは作者が多忙で、OSSのメンテナンスに十分な時間を確保できていないことがその主な理由のようです。
過去にはメンテナーを募集するissueが立てられ、実際に1名が追加されましたが、現在もメンテナーによる積極的な更新や管理があまり行われていません。
理想のResultライブラリを再発明
これらの課題を解決するために、私は新しいResult型ライブラリをゼロから設計・実装することにしました。
neverthrow
の思想をリスペクトしつつ、より「関数的(FP)」なアプローチで再構成したのが byethrowです。
byethrowのコアデザイン
byethrow
は、neverthrow
の良い部分を引き継ぎながら、より柔軟で実用的な設計を目指しました。
- 拡張性: 利用者側で独自の操作を簡単に追加可能
- 合成しやすい: 同期/非同期をまたいでもパイプラインが崩れない
- 最小限:
Result
にフォーカスし、どのコードベースにも無理なく持ち込める
シンプルなオブジェクト構造
byethrow
のResultは、クラスではなくシンプルなオブジェクトで表現されます。
import { Result } from '@praha/byethrow';
const success = Result.succeed(42);
// { type: 'Success', value: 42 }
const failure = Result.fail(new Error('Something went wrong'));
// { type: 'Failure', error: Error }
クラスではないため、利用者は自由に関数を追加できます。
また、pipe()
を使って関数を組み合わせる、柔軟でFP(関数型プログラミング)的な設計になっています。
const validateId = (id: string) => {
if (!id.startsWith('u')) {
return Result.fail(new Error('Invalid ID format'));
}
return Result.succeed(id);
};
const findUser = Result.try({
try: (id: string) => ({ id, name: 'John Doe' }),
catch: (error) => new Error('Failed to find user', { cause: error }),
});
const result = Result.pipe(
Result.succeed('u123'),
Result.andThrough(validateId),
Result.andThen(findUser),
);
if (Result.isSuccess(result)) {
console.log(result.value); // { id: 'u123', name: 'John Doe' }
}
同期/非同期APIの統一
byethrow
では、Resultが同期か非同期かを意識する必要がありません。
import { Result } from '@praha/byethrow';
// 同期でも非同期でも同じAPIで扱える
const syncResult: Result.Result<string, Error> = Result.succeed('value');
const asyncResult: Result.ResultAsync<string, Error> = Result.succeed(Promise.resolve('value'));
// Promiseを返せば、自動的に非同期Resultに昇格
const combined: Result.ResultAsync<string, Error> = Result.pipe(
Result.succeed('value'),
Result.andThen((value) => Result.succeed(value)),
Result.andThen((value) => Result.succeed(Promise.resolve(`${value} async`))),
);
succeed()
やandThen()
が自動的にPromiseを検出し、非同期Resultに昇格させるため、 開発者は同期/非同期を意識せず、統一されたAPIで処理を組み立てられます。
neverthrowにはない便利な関数・型群
byethrow
には、neverthrow
にはない強力な機能が多数含まれています。
bind: オブジェクトにプロパティを追加
bind()
は、Resultの成功値がオブジェクトの場合、新しいプロパティを型安全に追加できる関数です。
import { Result } from '@praha/byethrow';
const result = Result.pipe(
Result.succeed({ name: 'Alice' }),
Result.bind('age', () => Result.succeed(20)),
Result.bind('email', () => Result.succeed('alice@example.com')),
);
// result: Success<{ name: string, age: number, email: string }>
バリデーションやデータ取得パイプラインで、段階的にオブジェクトを構築していく際に非常に便利です。
collect/sequence: 配列やオブジェクトのResultをまとめる
複数のResultを並列で実行し、すべてが成功したら結果を結合、1つでも失敗したらエラーをまとめる。そんな処理を簡潔に書けます。
import { Result } from '@praha/byethrow';
// オブジェクトの場合
const result = Result.collect({
user: fetchUser(),
posts: fetchPosts(),
comments: fetchComments(),
});
// Success<{ user: User, posts: Post[], comments: Comment[] }>
// または Failure<Error[]>
// 配列の場合
const results = Result.collect([
fetchUser(),
fetchPosts(),
fetchComments(),
]);
// Success<[User, Post[], Comment[]]>
// または Failure<Error[]>
InferSuccess/InferFailure: 型の自動抽出
ResultやResultを返す関数から、成功時の型や失敗時の型を自動で取り出せます。
関数の戻り値からも自動的に推論できるのがポイントです。
もちろんResultだけではなく、ResultAsyncにも対応しています。
import { Result } from '@praha/byethrow';
type R = Result.Result<number, string>;
type RSuccess = Result.InferSuccess<R>; // number
type RFailure = Result.InferFailure<R>; // string
type AR = Result.ResultAsync<boolean, Error>;
type ARSuccess = Result.InferSuccess<AR>; // boolean
type ARFailure = Result.InferFailure<AR>; // Error
const fn = (value: number) => value < 0 ? Result.fail('Negative value') : Result.succeed(value);
type FnSuccess = Result.InferSuccess<typeof fn>; // number
type FnFailure = Result.InferFailure<typeof fn>; // Negative value
まとめ
byethrow
は、Effectのような包括的なエコシステムを目指すのではなく、「Result型にフォーカス」した軽量で実用的な設計を追求しています。
現在もbyethrow
は積極的に開発中で、以下のような改善を進めていく予定です。
- ESLintルールによるベストプラクティスの強制
- 型推論のさらなる改善
- ドキュメントの充実
OSSとして開発しているので、興味がある方はぜひリポジトリをチェックしてみてください。
Star🌟やPRも大歓迎です。
TypeScriptでのエラーハンドリングに悩んでいる方、neverthrow
に物足りなさを感じている方は、ぜひbyethrow
を試してみてください。
皆さんのフィードバックをお待ちしています。
その他にも、PrAhaではTypeScriptでの開発に役立つライブラリをいくつか公開していますので、興味のある方はぜひチェックしてみてください!
Discussion