TL;DR
- TypeScriptの
throwは型システムの外にあり、エラーの型情報が失われる - neverthrowの
Result<T, E>型により、エラーを型安全に「返す」ことができる map/andThenによるメソッドチェーンで、Railway Oriented Programmingが可能ResultAsyncで非同期処理のエラーハンドリングにも自然に対応Result.fromThrowableでサードパーティとの境界を安全に扱える
はじめに
TypeScriptで開発していると、エラーハンドリングに対してどこか居心地の悪さを感じたことはないでしょうか。関数の戻り値には丁寧に型をつけているのに、「この関数はどんなエラーを投げるのか」という情報は型システムの外側に追いやられています。
neverthrowは、この問題を解決するためのライブラリです。RustやScalaといった言語で採用されているResult型の考え方をTypeScriptに持ち込み、エラーを「投げる」のではなく「返す」ことで、型安全なエラーハンドリングを実現します。
本記事では、neverthrowが何を解決し、なぜTypeScriptコードをより堅牢にしてくれるのかを掘り下げていきます。
TypeScriptのエラーハンドリングが抱える構造的な問題
throwは型システムの穴である
TypeScriptの型システムは強力です。関数の引数や戻り値に型を付け、コンパイル時に多くのバグを検出できます。しかし、エラーハンドリングに関しては大きな盲点があります。
throwで投げられた例外は、型システムに一切反映されない
function parseJSON(input: string): User {
const data = JSON.parse(input); // SyntaxErrorを投げる可能性
return data as User;
}
この関数のシグネチャを見ただけでは、SyntaxErrorが飛んでくる可能性があることは分かりません。呼び出し側がtry/catchを書くかどうかは完全に開発者の記憶と規律に委ねられています。Javaには検査例外(checked exception)という、関数が投げ得る例外をシグネチャに明示する仕組みがあるそうですが、TypeScript(というよりJavaScript)にはそのような仕組みは存在しません。
catchブロックの中はunknown
仮にtry/catchを忘れずに書いたとしても、catch節で捕まえたerrorの型はunknownです。
try {
const user = parseJSON(rawData);
} catch (error) {
// errorはunknown。何が飛んできたか分からない。
// instanceofなどで判別するしかない。
}
せっかくTypeScriptを使っているのに、エラー処理の部分だけまるでプレーンなJavaScriptに逆戻りしたかのような状態になります。これは単なる不便さではなく、バグの温床です。
暗黙の制御フロー
throwはある意味でgotoに似ています。関数の途中から、コールスタック上の不特定の位置へ制御が飛びます。どこで例外が捕捉されるかは、呼び出し元をさかのぼってtry/catchを探さなければ分かりません。コードを読む人間にとっても、静的解析ツールにとっても、この暗黙的な制御フローは追跡が困難です。
ある開発者が関数内でエラーをthrowし、別の開発者がその関数を使うとき、throwの存在を知らなければ未処理のまま放置されます。
neverthrowのアプローチ:エラーを「返す」
Result型という発想
neverthrowの核となるのはResult<T, E>という型です。これは成功(Ok<T>)か失敗(Err<E>)のどちらかを表します。
import { ok, err, Result } from 'neverthrow';
function parseJSON(input: string): Result<User, ParseError> {
try {
const data = JSON.parse(input);
return ok(data as User);
} catch {
return err({ type: 'parse', message: '不正なJSONです' });
}
}
この関数のシグネチャには、「成功すればUserが、失敗すればParseErrorが返る」という情報が明示的に含まれています。呼び出し側はこの関数が失敗し得ることを型レベルで知ることができ、コンパイラがエラー処理の漏れを指摘してくれます。
呼び出し側のコード
const result = parseJSON(rawData);
if (result.isOk()) {
console.log(result.value.name); // TypeScriptはvalueがUser型だと推論
} else {
console.error(result.error.message); // TypeScriptはerrorがParseError型だと推論
}
isOk()とisErr()はTypeScriptの型ガードとして機能するため、条件分岐の中で自動的に型が絞り込まれます。catchブロックのようにunknownと格闘する必要はありません。
neverthrowが解決する5つの問題
1. エラーの型安全性
最も根本的な解決は、エラーに型が付くことです。Result<User, NetworkError | ParseError>と書けば、この関数が返し得るエラーの種類が網羅的に分かります。呼び出し側はswitch文などを使って、各エラーを漏れなく処理できます。
const result = fetchAndParseUser(id);
if (result.isErr()) {
switch (result.error.type) {
case 'network':
retryWithBackoff();
break;
case 'parse':
logCorruptData();
break;
default: {
const _: never = result.error
}
}
}
新しいエラー型が追加されたとき、コンパイラが未処理のケースを教えてくれます。throwベースのコードでは、こうした保証は得られません。
2. エラー処理の強制
Result型を返す関数を使うとき、その結果を処理せずに値を取り出す方法はありません。通常の戻り値のようにresult.valueで直接アクセスすることはできず、isOk()で確認するか、map/andThenなどのメソッドを使って変換する必要があります。
さらにneverthrowには公式のESLintプラグイン(eslint-plugin-neverthrow)があり、Resultを消費せずに放置するコードを静的に検出できます。これにより「エラーを握りつぶす」というありがちなミスがチーム全体で防止されます。
3. 処理を直線的につなげられる
neverthrowの真価は、単にOk/Errを返すだけにとどまりません。Resultに対してmap、mapErr、andThenといったメソッドが用意されており、複数の処理をメソッドチェーンでつなげられます。
const userName = safeFetch(`/users/${id}`) // Result<Response, NetworkError>
.andThen(safeJsonParse) // Result<unknown, NetworkError | ParseError>
.andThen(safeValidate(userSchema)) // Result<User, NetworkError | ParseError | ValidationError>
.map(user => user.name); // Result<string, NetworkError | ParseError | ValidationError>
途中のどこかで失敗が起きた時点で後続の処理はスキップされ、エラーがそのまま最後まで伝播する
try/catchのネストを深くすることなく、成功パスを直線的に書けるのが大きな利点です。このパターンは「Railway Oriented Programming」とも呼ばれます。
エラー型は自動的に合成(union)されるため、最終的なResultの型を見れば、パイプライン全体でどんなエラーが起こり得るかが一目で分かります。
4. 非同期処理との統合
現実のアプリケーションでは、データベースクエリやHTTPリクエストなど非同期処理が大量に存在します。neverthrowはこのためにResultAsync型を提供しています。
import { ResultAsync } from 'neverthrow';
const userResult: ResultAsync<User, DbError> = ResultAsync.fromPromise(
prisma.user.findUnique({ where: { id } }),
(e) => ({ type: 'db' as const, cause: e })
);
ResultAsyncはPromise<Result<T, E>>のラッパーですが、単なるラッパーではありません。thenable(.then()可能)なのでawaitできるうえ、mapやandThenといったメソッドをawaitせずに直接チェインできます。つまり、同期的なResultと同じ感覚で非同期処理のエラーハンドリングを記述できます。
const userName = ResultAsync.fromPromise(fetch(`/api/users/${id}`), toNetworkError)
.andThen(res => ResultAsync.fromPromise(res.json(), toParseError))
.map(user => user.name);
// ResultAsync<string, NetworkError | ParseError>
5. サードパーティとの境界を安全に扱える
現実のプロジェクトでは、すべてのコードを自分たちで書いているわけではありません。外部ライブラリやブラウザAPIは例外を投げる前提で設計されています。neverthrowはResult.fromThrowableを提供し、例外を投げる関数をResultを返す関数に変換できます。
const safeJsonParse = Result.fromThrowable(
JSON.parse,
() => ({ type: 'parse' as const, message: 'Invalid JSON' })
);
const result = safeJsonParse('{"valid": true}');
// Result<any, { type: 'parse'; message: string }>
これにより、アプリケーションの境界で例外をResultに変換し、内部では一貫してResultベースのエラーハンドリングを行うアーキテクチャが可能になります。try/catchは外部との境界にのみ閉じ込め、ビジネスロジック層では型安全なエラー伝播を徹底できます。
おわりに
neverthrowが解決するのは、一言でいえば「TypeScriptの型システムがエラーを追跡できない」という問題です。
具体的には、エラーに型がつかないこと、エラー処理が強制されないこと、例外による暗黙的な制御フローの3つの問題を、Result<T, E>というシンプルな抽象で解決します。加えて、メソッドチェーンのための豊富なメソッド群、非同期処理との自然な統合、サードパーティとの境界を安全に扱う手段を提供します。
エラーを「投げる」から「返す」へ
エラーを値として返すというアプローチは、RustやGoなどの言語でも採用されているようですが、TypeScriptの世界ではまだ広く浸透しているとは言い難いでしょう。neverthrowは、この考え方をTypeScriptに取り入れるためのライブラリです。すべてのTypeScript開発者が一度は触れてみる価値があります。
