Argano の鈴木です。この記事では neverthrow を使って安全にエラーハンドリングを行う方法について紹介します。
neverthrowとは
TypeScript において例外(throw)の代わりに成功または失敗を型情報として含む Result 型を用いて、成功/失敗を安全に表現・処理することができるライブラリです。
neverthrow を使うことで try-catch 構文を使用せずに関数型アプローチでエラー処理を実装し、エラーの隠蔽を防ぎ、可読性と信頼性を向上させることができます。
エラーの「不透明性」を排除する
エラーが発生する可能性のある関数であっても、標準的な throw を使った実装では利用側がその事実に気づかずエラーハンドリングを忘れてしまい、結果としてアプリケーションがクラッシュしてしまう危険があります。
例外を発生させる関数の例を見てみましょう。
function division(a: number, b: number): number {
if (b === 0) {
throw new Error("0で割ることはできません");
}
return a / b;
}
// 利用側は division が Error を投げることを型から判別できない
const value = division(1, 0);
console.log(value); // ここで実行時エラーが発生し、クラッシュする
この実装の問題点は、関数の型定義が (number, number) => number であり、「エラーを投げる」という情報が型に含まれていないことです。そのため、利用者は try-catch が必要であるかと判断するには内部実装を読む必要が出てきます。
neverthrow を使うと divide 関数を以下のように書くことができます。
import { type Result, ok, err } from "neverthrow";
function divide(a: number, b: number): Result<number, Error> {
if (b === 0) {
return err(new Error("0で割ることはできません"));
}
return ok(a / b);
}
const result = divide(1, 0);
if (result.isOk()) {
console.log(result.value);
} else if (result.isErr()) {
// エラーハンドリングを行う
}
正常であれば ok, エラーの場合には err でラップして返すことで、 divide 関数の戻り値は Result<number, Error> 型になります。
この関数の利用者は、結果を取り出す前に Result.isOk() や Result.isErr() を使って状態を確認することがコンパイラによって強制された上で、Result.value で成功時の値、もしくは Result.error で error オブジェクトを取り出すことができます。これにより、「エラーの存在を知らずに無視してしまう」というミスを防ぐことができます。
上記のようなシンプルな例だけでなく、「API から返ってきたデータが想定外の形式だった場合」や「ユーザー入力がバリデーションに失敗した場合」など、実行してみるまで成功するか分からない処理すべてにおいてこの考え方が役立ちます。
Result型
neverthrow の中心となるのは、成功(Ok)と失敗(Err)の状態を一つにまとめた Result<T, E> 型です。
- T (Type): 処理が成功したときに返ってくる値の型
- E (Error): 処理が失敗したときに返ってくるエラーの型
関数を利用する側にとって、関数の戻り値を Result 型で受けとることで「エラーの発生」と「エラーハンドリング」をセットで記述できるようになります。
try-catch を使用する場合、try 節で正常系の処理を全て書き終えた後に catch 節を書くことになるため、エラーが発生する可能性のある処理とそのエラーハンドリングを書く場所が遠くなる可能性があります。
neverthrow を使うことで、 エラーが発生する可能性のある関数を実行してすぐにエラーハンドリング処理を書くことができます。
// try-catchでの書き方
try {
const value = divide(1, 0);
// 正常系の処理
・
・
・
} catch (error) {
// divide関数のエラーハンドリング
}
// neverthrowを使う場合
const result = divide(1, 0);
if(result.isErr()) {
// divide関数のエラーハンドリング
}
const value = result.value
// 正常系の処理
・
・
・
このように、エラーの発生とハンドリングを近くに書くことができるため、可読性・保守性の向上に繋がります。
neverthrowを使った非同期処理
neverthrow はもちろん非同期処理でも使えます。ここでは、標準的な Promise<Result<T, E>> を返す書き方と、より強力な ResultAsync 型を使った書き方を比較してみます
Promise<Result<T, E»を使った実装
User 型を返す webAPI をラップした fetchUser 関数は Promise<Result<T, E>> で型定義すると次のように書けます。
async function fetchUser(): Promise<Result<User, Error>> {
try {
const res = await fetch("api/user");
if (!res.ok) {
return err(new Error("fetch User Error"));
} else {
const data = (await res.json()) as User;
return ok(data);
}
} catch (error) {
// 例外を拾って "値" としての Err に変換する
return err(error instanceof Error ? error : new Error(String(error)));
}
}
使う側は通常通り await で待って、 Result<User, Error> 型を取得することができますが、fetchUser 関数内では try-catch 構文が使われています。
ResultAsyncを使った実装
ResultAsync を使うと、性質の異なるエラーを一つのパイプラインで処理できるようになります。
import { ResultAsync } from "neverthrow";
function fetchUser(): ResultAsync<User, Error> {
return ResultAsync.fromPromise(
fetch("api/user"),
(e) => new Error(`Network error: ${e}`), // (1) 例外を値に変換
).andThen((res) => {
// res.ok の判定もメソッドチェーンの中で行える
if (!res.ok) {
return err(new Error("fetch User Error")); // (2) サーバーエラーのハンドリング
}
// data.json() も Promise なので ResultAsync に変換して繋ぐ
return ResultAsync.fromPromise(
res.json() as Promise<User>,
(e) => new Error(`JSON parse error: ${e}`), // (3) 別の非同期処理(JSONパース)への接続
);
});
}
ResultAsync.fromPromise は、第一引数の Promise が reject された場合、その理由を第二引数の関数で受け取り、自動的に Err オブジェクトへと変換します。これにより、関数全体を try-catch で囲む必要がなくなります。
また、ネットワーク断絶などの「例外」は、この時点で「エラーという値」として扱われます。
andThen メソッドは、前の処理が 成功(Ok)だった時のみ 実行されます。 fetch が成功して Response オブジェクトが返ってきたら、次に res.ok をチェックします。もしここでエラー(res.ok === false)であれば、err() を返すことで以降の処理はスキップされ、最終的な結果は Err になります。
Promise<Result> と ResultAsync は、利用側から見ればどちらも「非同期で Result を返す」という点で共通しています。どちらの形式であっても、呼び出し側は同じように結果を扱うことが可能です。
// どちらの定義であっても、await すれば Result 型が手に入る
const result = await fetchUser();
if (result.isOk()) {
console.log(result.value); // User型
} else if (result.isErr()) {
console.error(result.error); // Error型
}
まとめ
neverthrow を使うことで次のようなメリットがあることが分かりました。
- try-catchを書かずに済むようになる
- エラーハンドリング忘れがなくなる
- 関数の型定義のドキュメントとしての質の向上
学習コストや記述量の増加といったトレードオフはありますが、「型によって守られている」という安心感は、大規模なアプリケーションや長期的なプロジェクトにおいて、それらを上回るリターンをもたらすと思います。
