はじめに
TypeScriptでエラーハンドリングをするとき、 throw を使うことがあると思いますが、throw には見えない落とし穴があります。この記事では、throw の問題点と、それを解決してくれるライブラリ neverthrow について解説します。
なぜ throw は危ないの?
一見すると、throw を使ったエラーハンドリングはシンプルで分かりやすく見えます。
function risky(): string {
if (Math.random() > 0.5) {
throw new Error('失敗しました');
}
return '成功!';
}
しかし、この関数の戻り値の型は string。エラーになる可能性があるにも関わらず、型情報からそれが読み取れません。
つまり、関数名や型からは、失敗する可能性があることが分かりません。
const result = risky(); // TypeScriptはstringだと認識
console.log(result.toUpperCase()); // 実行時にクラッシュの可能性
このように、throw は TypeScript の型システムの外にあるため、型チェックで安全性を担保できません。
neverthrowとは?
neverthrow は、関数の結果を 「成功か失敗か」 を型で表現できるようにする TypeScript ライブラリです。
Rustの Result 型にインスパイアされています。
npm install neverthrow
neverthrow のメリット
1. 型でエラーを扱える
-
Result<T, E>型で、成功 (Ok) か失敗 (Err) を明示できる。 - IDEで補完されるので、どちらのケースも漏れなく書ける。
2. try/catch が不要で読みやすい
- 関数の戻り値で処理を分岐できるため、ネストが減る。
- 失敗が予測可能になる。
3. 安全に処理をつなげられる
-
map,mapErr,andThenなどのメソッドを使って、成功時だけ処理を続けられる。
基本の使い方
import { Result, ok, err } from 'neverthrow';
function divide(a: number, b: number): Result<number, string> {
if (b === 0) {
return err('ゼロで割ることはできません');
}
return ok(a / b);
}
const result = divide(10, 2);
if (result.isOk()) {
console.log('成功:', result.value);
} else {
console.log('エラー:', result.error);
}
比較しながら理解する:throw vs neverthrow
・throw を使った場合(型システムの外)
function divide(a: number, b: number): number {
if (b === 0) {
throw new Error('ゼロで割ることはできません');
}
return a / b;
}
この関数の型は divide(a: number, b: number): number。
でも実際には b === 0 のときに throw が発生します。
TypeScriptの型的には "number を返す" とされているのに、実行時には「返さない(= エラーになる)」可能性がある。
問題点:
- 呼び出し側からは 「失敗する可能性がある」ことが分からない。
- IDEの型情報では
numberが返るとしか見えない。 - catch し忘れるとアプリがクラッシュする。
・neverthrow を使った場合(型システムの中)
import { Result, ok, err } from 'neverthrow';
function divide(a: number, b: number): Result<number, string> {
if (b === 0) {
return err('ゼロで割ることはできません');
}
return ok(a / b);
}
この関数の型は Result<number, string>。
これは「成功すれば number、失敗すれば string(エラーメッセージ)」ということを、型が正確に表しています。
利点:
- 呼び出し側は
.isOk()や.isErr()で結果を安全に扱える。 - IDEでも「成功か失敗か」を明示的に意識できる。
- catch を忘れてクラッシュすることはない。
実践:複数の処理をつなげてみる
function parseNumber(s: string): Result<number, string> {
const num = Number(s);
return isNaN(num) ? err('数値ではありません') : ok(num);
}
function square(n: number): Result<number, string> {
return ok(n * n);
}
const result = parseNumber("5").andThen(square);
result.match({
ok: (v) => console.log("結果:", v),
err: (e) => console.error("エラー:", e),
});
処理の流れとしては
-
parseNumber→ 文字列を数値に変換(失敗したらerr) -
square→ 数値を2乗してokで返す -
andThen→ 前が成功なら次の処理へ、失敗ならスキップ -
match→ 成功時・失敗時の処理を分けて実行
実行結果の例
-
"5"を渡す → 結果:25 -
"abc"を渡す → エラー:数値ではありません
ポイント
-
andThenを使うと、安全に処理をつなげられる -
matchで 成功・失敗を型で分けて処理できる -
neverthrowの基本的な流れがこのコードに詰まってる!
おわりに
TypeScriptで安全なコードを書くためには、エラー処理を型の中に閉じ込めることがとても大切です。
neverthrow を使えば、「エラーになる可能性」を明示的に型として扱えるので、より堅牢で読みやすいコードが書けます。