関数のシグネチャは User を返すと言っている。嘘だ。実際には User を返すか、爆発するかのどちらかだ。型システムはその後者の分岐を知らない。
これが例外ベースのエラーハンドリングの根本的な不誠実さだ。すべての throw は、コンパイラが見えず、チェックできず、強制もできない制御フローの経路だ。結果、型チェックは完璧に通るのに、誰かが3階層上で try/catch を忘れただけで本番環境でクラッシュするコードができあがる。
明示的なエラー返却はこれを修正する。失敗を型システムの第一級市民にする。問題は、その誠実さの代償として人間工学のコストを払う価値があるかどうかだ。
型レベルの正しさがエラーにとって何を意味するか
型レベルの正しさとは、型が関数が生み出しうるものについて真実を語ることを意味する。関数が失敗しうるなら、返り値の型がそれを示すべきだ。失敗しえないなら、返り値の型が成功を保証すべきだ。
Rust はこれを Result<T, E> でエンコードする:
fn parse_port(s: &str) -> Result<u16, ParseIntError> {
s.parse()
}
シグネチャは完全だ。呼び出し側は2つの結果があることを知っている。コンパイラは両方を処理することを強制する。
TypeScript はデフォルトではそうなっていない。throw する関数は、throw しない関数と見た目上区別がつかない:
function parsePort(s: string): number {
const n = parseInt(s, 10);
if (isNaN(n) || n < 0 || n > 65535) {
throw new Error(`Invalid port: ${s}`);
}
return n;
}
返り値の型は number と言っている。本来は number | Error、あるいはより正確には Result<number, Error> と言うべきだ。しかし実際にはそうなっていないため、呼び出し側には失敗に備える必要があるという信号がない。
throw されたエラーはコールグラフのどこに潜むか
コールスタックが深くなるにつれて被害は累積する。リーフ関数で投げられたエラーは、中間のすべての層に対し、それをキャッチするか暗黙的に伝播させるかを強いる。それらの伝播経路は、どの型シグネチャにも現れない。
コンフィグローダーがパーサーを呼び、パーサーがバリデーターを呼ぶとしよう。バリデーターが throw すれば、パーサーとローダーも throwable になる。しかしそれらの型は変わらない。loadConfig() を見ただけでは失敗する可能性があるかどうかわからない。すべての依存関係のすべての行を読むか、ランタイムのクラッシュを待つしかない。
これは型があるべき働きと真逆だ。型は契約を理解するために実装を読む必要をなくすために存在する。
明示的なエラー返却はこれを反転させる。失敗の経路はすべての層で可視化される:
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
function parsePort(s: string): Result<number, string> {
const n = parseInt(s, 10);
if (isNaN(n) || n < 0 || n > 65535) {
return { ok: false, error: `Invalid port: ${s}` };
}
return { ok: true, value: n };
}
function loadConfig(raw: Record<string, string>): Result<Config, string> {
const portResult = parsePort(raw.port);
if (!portResult.ok) return portResult;
const host = raw.host || "localhost";
return { ok: true, value: { port: portResult.value, host } };
}
これで loadConfig のシグネチャがすべてを語る。本体を1行も読まなくても、失敗する可能性があることを知っている。
本当のトレードオフは表現力ではなく人間工学だ
正直なアプローチはより冗長になる。throw new Error(...) の代わりに { ok: false, error: ... } を書くのは誰も楽しくない。視覚的ノイズは積み重なり、複数の失敗しうる操作を連鎖させるときには特に目立つ。
だからこそ、チェック例外を持つほとんどの言語がそれを廃止したのだ。Java の checked exceptions は型レベルで明示的な処理を強制しようとしたが、構文が過剰に厳しかった。Rust の ? 演算子や map や and_then のような Result コンビネータは、その人間工学のほとんどを取り戻している。TypeScript に ? 演算子はないが、ヘルパー関数で近づけることはできる:
function map<T, U, E>(result: Result<T, E>, fn: (value: T) => U): Result<U, E> {
return result.ok ? { ok: true, value: fn(result.value) } : result;
}
function flatMap<T, U, E>(
result: Result<T, E>,
fn: (value: T) => Result<U, E>
): Result<U, E> {
return result.ok ? fn(result.value) : result;
}
それでも Rust ほどスムーズではない。TypeScript を書くなら、neverthrow のようなライブラリがチェーン可能なメソッドを持つ Result 型を提供し、ほぼネイティブのような手触りを実現する。オーバーヘッドは実在するが、一度ヘルパーを整えれば、見た目ほど大きくはない。
もう1つコストがある。throw されたエラーのスタックトレースは豊かで自動的だ。返却されたエラーは単なる値に過ぎない。スタックトレースが欲しければ、自分で構築する必要がある。デバッグの観点からはこれは重要だ。ビジネスロジックにおける予測可能なエラーハンドリングの観点からは、通常は重要ではない。
throw がいまだに理にかなう場面
例外をゼロにすべきだと主張しているわけではない。ある失敗は真に例外的であり、通常のエラーモデルの一部であるべきではない。コンフィグ読み込み中のファイル不在は予期される失敗だ。メモリ不足エラーはそうではない。
経験則: 失敗がドメインの一部なら、それを返す。プログラミング上のバグや壊滅的なシステム障害なら、throw する。JSON.parse が不正な入力に対して throw するのは面倒だ。不正な JSON はよくあることだからだ。Array.prototype.map が null の配列に対して throw するのは問題ない。null に対して .map を呼ぶのはバグだからだ。
実践では、I/O 境界、パーサー、バリデーター、ビジネスルールはエラーを返すべきだ。不変条件、アサーション、真に壊滅的な状況は引き続き throw してよい。
API 境界から始めろ
コードベース全体をリファクタリングする必要はない。明示的なエラー返却を導入する価値が最も高いのは、パブリック API と I/O 層だ。これらは、呼び出し側が何がうまくいかないかを知る必要がある境界だ。
1つのモジュールを選ぶ。返り値の型を Result<T, E> に変える。呼び出し側を更新する。感触を確かめる。このパターンは特定の意味で中毒性がある: 欠落した try/catch ブロックをスタックトレースを漁って探す必要がなくなったとき、throw された例外がどれだけの認知オーバーヘッドを強いていたか、気づくのだ。
エラーに対する型レベルの正しさは、完璧なコードを書くことではない。より嘘の少ないコードを書くことだ。