古来より例外を内容に関わらず握り潰すことが粗悪なコードとして忌避されてきたが、例外を使わない関数型プログラミングでも同様のことが往々にして起きることを発見した。なんなら例外を使うプログラミングスタイルよりも起きやすい可能性すらあると思っている。そこでここでは関数型プログラミングでのエラー握り潰し問題について書く。
実際に関数型プログラミングを取り入れているチームでの経験から得た知見を書く。
非関数型プログラミングにおける例外握り潰し問題
非関数型プログラミングにおける例外握り潰し問題とは例えば以下のようなコードで発生する。
async function listItems(start: number, end: number) {
if(start > end) throw new RangeError('"start" must be lesser than "end"');
const resp = await fetch(`https://some.api.com/items?start=${start}&end=${end}`);
return await resp.json();
}
class GetFirstNItemsUsecaseError extends Error {}
async function getFirstNItemsUsecase(n: number) {
try {
return await listItems(1, n);
} catch(e) {
throw new GetFirstNItemsUsecaseError('Error occured in getFirstNItemsUsecase'); // ★
}
}
このコードにはgetFirstNItemsUsecaseでエラーが起きたときその原因が外から全く不明になるという問題がある。内部で起きた例外が全て一つのError occured in getFirstNItemsUsecaseというメッセージにまとめられてしまうので、原因の特定が困難になる。
地獄の原因特定作業
実際にgetFirstNItemsUsecase(0)を呼び出すコードをNode.jsで実行した結果の出力が以下である。
/home/silane/js_error_test/index.js:12
throw new GetFirstNItemsUsecaseError('Error occured in getFirstNItemsUsecase'); // ★
^
GetFirstNItemsUsecaseError: Error occured in getFirstNItemsUsecase
at getFirstNItemsUsecase (/home/silane/js_error_test/index.js:12:11)
at async /home/silane/js_error_test/index.js:17:3
この出力が本番環境のログに出てきてあなたが原因特定をしないといけない場面を想像してほしい。上記の通りログとして出てくるのはError occured in getFirstNItemsUsecaseというメッセージだけ、スタックトレースはそのメッセージを投げている★の場所から先しか出ない。これでは具体的にどこでどんな問題が起きたのかを見極めることは困難である。今回ならnに0が渡ったことが原因なわけだが、そのことはこの出力からは分からず、some.api.comが落ちていたとか様々な考えられる原因を一つずつ潰していかなければならない。
これが例外握り潰し問題の一つの形である。つまり例外の詳細情報を握り潰すようなコードを書くことでエラーが起きたときに原因特定が困難になる。
この問題の解決策はいくつかある。一つ目は例外のチェインを使う方法、二つ目はそもそも例外をキャッチしないことである。
解決策 例外チェイン
多くの言語では例外を投げるときにその原因となった例外を記録する機能がある。JavaScriptでも同様の事を以下のようにcauseを使って原因となった例外を指定することで行える。
async function getFirstNItemsUsecase(n: number) {
try {
return await listItems(1, n);
} catch(e) {
throw new Error('Error occured in getFirstNItemsUsecase', { cause: e });
}
}
これでgetFirstNItemsUsecase(0)を呼び出すとNode.js v18.17.1では以下のような出力がされる。
/home/silane/js_error_test/index.js:12
throw new GetFirstNItemsUsecaseError('Error occured in getFirstNItemsUsecase', { cause: e }); // ★
^
GetFirstNItemsUsecaseError: Error occured in getFirstNItemsUsecase
at getFirstNItemsUsecase (/home/silane/js_error_test/index.js:12:11)
at async /home/silane/js_error_test/index.js:17:3 {
[cause]: RangeError: "start" must be lesser than "end"
at listItems (/home/silane/js_error_test/index.js:2:26)
at getFirstNItemsUsecase (/home/silane/js_error_test/index.js:10:18)
at /home/silane/js_error_test/index.js:17:9
at Object.<anonymous> (/home/silane/js_error_test/index.js:18:3)
at Module._compile (node:internal/modules/cjs/loader:1256:14)
at Module._extensions..js (node:internal/modules/cjs/loader:1310:10)
at Module.load (node:internal/modules/cjs/loader:1119:32)
at Module._load (node:internal/modules/cjs/loader:960:12)
at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:81:12)
at node:internal/main/run_main_module:23:47
}
[cause]:から始まる行以降にあるように、原因として指定した例外の型やメッセージやスタックトレースも出力されるため、エラーの発生個所を見失うことがない。このcauseによる原因例外の指定は好きなだけチェインできる。
この例外のチェインは例外を扱う上での必須の機能だと思っている。(JavaScriptには比較的最近までなかったのだが…)
代替策 例外をキャッチするのをやめる
代替策としてそもそも例外をキャッチしないで素通りさせるという方法もある。つまりgetFirstNItemsUsecase関数は以下のようになる。
async function getFirstNItemsUsecase(n: number) {
return await listItems(1, n);
}
これでgetFirstNItemsUsecase(0)を呼び出した場合の出力は以下である。
/home/silane/js_error_test/index_nocatch.js:2
if (start > end) throw new RangeError('"start" must be lesser than "end"');
^
RangeError: "start" must be lesser than "end"
at listItems (/home/silane/js_error_test/index_nocatch.js:2:26)
at getFirstNItemsUsecase (/home/silane/js_error_test/index_nocatch.js:9:16)
at /home/silane/js_error_test/index_nocatch.js:13:9
at Object.<anonymous> (/home/silane/js_error_test/index_nocatch.js:14:3)
at Module._compile (node:internal/modules/cjs/loader:1256:14)
at Module._extensions..js (node:internal/modules/cjs/loader:1310:10)
at Module.load (node:internal/modules/cjs/loader:1119:32)
at Module._load (node:internal/modules/cjs/loader:960:12)
at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:81:12)
at node:internal/main/run_main_module:23:47
上記の通りこれなら当然スタックトレースが途中で切れることは無い。しかしこれが関数の設計として適切かどうかは考える必要がある。getFirstNItemsUsecase(n: number)の出すエラーとして"start" must be lesser than "end"というエラーメッセージは意味不明である。ここで起きているのは関数内部の実装詳細の外部への漏洩であり、適切なカプセル化ができていないと言える。
📌例外は何もしないという最も怠惰な方法を取れば、エラー発生地点を見失うことがない
ただともかく、例外は何もしないという最も怠惰な方法を取れば、エラー発生地点を見失うことがない。これは例外の素晴らしい性質である。細かいことを考えないならとにかく余計なことをせず例外はなるべくキャッチしない、それだけを意識すればよい。
async function listItems(start: number, end: number) {
if(start > end) throw new RangeError('"start" must be lesser than "end"');
const resp = await fetch(`https://some.api.com/items?start=${start}&end=${end}`);
return await resp.json();
}
async function getFirstNItemsUsecase(n: number) {
return await listItems(1, n); // エラー処理を一切しない
}
getFirstNIemsUsecase(0);
error: Uncaught (in promise) RangeError: "start" must be lesser than "end"
if(start > end) throw new RangeError('"start" must be lesser than "end"');
^
at listItems (file:///home/silane/develop/main.ts:2:25)
at getFirstNItemsUsecase (file:///home/silane/develop/main.ts:8:16)
at file:///home/silane/develop/main.ts:11:1
関数型プログラミングにおけるResult握り潰し問題
関数型プログラミングでは例外を使わないわけだが、それでも同様の問題が起きうる。
関数型プログラミングでは「関数のシグネチャが嘘をつかない」という事を重要視し、エラーが起こりうる場合は例外ではなくそれを関数の戻り値として表現しようとする。一般にいうResult型である。
type Result<T, E> = { type: 'ok', value: T } | { type: 'error', error: E };
const Result = {
ok: <T,>(value: T) => ({ type: 'ok', value }),
error: <E,>(error: E) => ({ type: 'error', error }),
};
Resultの握り潰し問題とは以下のようなコードで発生する。
type RangeError = { type: 'RangeError' };
type ApiError = { type: 'ApiError' }; // 本来もっと詳細なエラー情報を持つべきだが省略
async function listItems(start: number, end: number): Promise<Result<Item[], RangeError | ApiError>> {
if(start > end) return Result.error({ type: 'RangeError' });
try {
const resp = await fetch(`https://some.api.com/items?start=${start}&end=${end}`);
return Result.ok(await resp.json());
} catch(e) {
return Result.error({ type: 'ApiError' });
}
}
type GetFirstNItemsUsecaseError = { type: 'GetFirstNItemsUsecaseError' };
async function getFirstNItemsUsecase(n: number): Result<Item[], GetFirstNItemsUsecaseError> {
const res = await listItems(1, n);
if(res.type === 'error') {
return Result.error({ type: 'GetFirstNItemsUsecaseError' });
}
return Result.ok(res.value);
}
基本的に例外がResult型に変わっただけである。エラーの種類が複数ある場合は判別共用体として表現する(RangeError | ApiErrorの部分)。
この場合も listItemsの返すエラー情報がgetFirstNItemsUsecaseの時点で失われており、エラーが起きたときの原因特定が困難になる。
📌Result型は何もしない訳にいかない、エラー時の処理方法を間違えるとエラー情報が失われる
ここでResultを使った方法が例外と比較して面倒なのが、エラーの場合の伝達を必ず明示的に行う必要があるという点である。例外の場合は何もしないという最も怠惰な方法でエラーの情報が失われることは無かった。しかし、Result型では必ずエラーかどうかのチェックを明示的に行う必要があり、ここでエラーの場合の処理方法を間違えるとエラーの情報を握り潰して原因が追えなくなる可能性が常につきまとう。あらゆるResult型を扱う場所全てでエラー情報を握り潰す処理になってないか注意を払いながらコーディングする必要があり、一か所でも不適切ならその下の呼び出し先からのエラー情報は消えてしまう。これはResult型のアプローチの大きな欠点だと思う。
例外は最も怠惰な方法(キャッチしない、何もしない)でエラー情報が失われない、Resultは最も怠惰な方法(エラー時の分岐処理は必須なのでするが、エラー情報を伝達しない)を取るとエラー情報が失われる。開発者全員がこのような異常系の処理に常に注意を払えるわけではないので、何もしなくてもエラー情報が失われない例外はこの点において優れていると言える。
| エラーの表現方法 | 最も怠惰な処理方法 | ←の場合の性質 |
|---|---|---|
| 例外 | キャッチしない、何もしない | ✅エラー情報は失われない |
Result型 |
エラー時の分岐処理は必須なのでするが、エラー情報を伝達しない | ❌エラー情報が失われる |
注意を払った上での解決策 Resultチェイン
結局何もしなくてもエラー情報が失われない例外には勝てないのだが、Result型のアプローチでも比較的面倒にならないやり方でエラー情報を伝達する方法を考えた。例外チェインと同じ考え方である。
type Result<T, E> = { type: 'ok', value: T } | { type: 'error', error: E, cause: unknown };
const Result = {
ok: <T,>(value: T) => ({ type: 'ok', value }),
error: <E,>(error: E, cause: unknown = undefined) => ({ type: 'error', error, cause }),
};
このようにResult型のエラーの場合にcauseというプロパティを追加しておく。これを使って以下のようにエラーの原因をチェインできるようにする。
if(res.type === 'error') {
return Result.error({ type: 'GetFirstNItemsUsecaseError' }, res);
}
また、以下のように一番上の呼び出し元ではエラーの情報を標準エラー出力等に出力する。これを忘れるのは呼び出しの一番上でエラー情報が握り潰されているという事で、結局一切のエラーの情報が残らない最悪のシステムになる。
import { serve } from '@hono/node-server';
import { Hono } from 'hono';
const app = new Hono();
app.get('/', async (c) => {
const res = await getFirstNItemsUsecase(Number(c.req.query('limit')));
if(res.type === 'error') {
console.error(JSON.stringify(res)); // これを忘れたら終わり❗
return new Response('', { status: 500 });
}
// 成功の場合の処理が続く...
});
serve(app);
まとめ「Resultは例外よりも丁寧なコーディングが求められる」
関数型プログラミングを始めたときは例外は悪!これからはResultを使って明示的にエラーを型で表す方法こそ正義だ!と思っていたが、例外は例外で良い部分があった。Resultによるエラー処理の方法は気を配らないとエラーの情報が簡単に失われて、エラー時の原因究明が困難になる。エラー情報が失われないよう丁寧なコーディングを全体で行う自信がないなら旧来通り例外を使った方がむしろ良い気もしている。