エラーハンドリング設計: 例外とResult型を境界で使い分けて復旧まで決める
try-catchを増やしても堅くならない。例外とResult型の使い分け、回復可否での分類、境界での集約、ユーザー向け文言と内部ログの分離、リトライをTypeScriptの動くコードで。
「このエラー、いい感じに直しといて」
Claude Codeにそう頼んだら、関数という関数が try で包まれて返ってきました。動きはする。でも、入力ミスも決済APIの停止も夜間バッチのコケも、ぜんぶまとめて catch され、最後は同じ 500 になっていました。
障害が起きたとき、僕はログを開いて固まりました。ユーザーが悪いのか、連携先が落ちてるのか、もう一回ジョブを流せば直るのか——どれも判別できない。try-catch の数は増えたのに、調査の手がかりはゼロに近かったんです。
エラーハンドリングは「try-catch を足す作業」ではありません。どこで失敗を分類し、どこで復旧し、どこで人間に知らせるかを先に決める設計です。今日はその設計を、例外とResult型の使い分けを軸に、コピペで動くコード込みで書きます。
この記事の要点
- エラーは「名前」より先に復旧方法で分ける。回復可能か/想定内か、で扱いが決まる。
- 例外とResult型は対立しない。想定外は例外で落とす、想定内の失敗はResult型で返すと読みやすい。
- 失敗の分類は**境界(入力・外部API・ジョブ・画面)**でやる。奥でバラバラに
catchしない。 - ユーザー向け文言と内部ログは分離する。画面にスタックトレースやSQLを出さない。
- リトライは万能薬ではない。時間で回復する失敗だけに絞る。
- 画面のエラー捕捉は Error Boundaryの実装、ログの読解は エラーメッセージの読み方 へ。この記事は「設計」に集中する。
エラーは「名前」ではなく「復旧方法」で分ける
最初にやりがちな失敗が、UserNotFoundError DbError ApiError みたいにエラー名から先に作ることです。名前を増やしても、呼び出し側が「で、どうすればいいの?」に答えられません。
先に決めるのは復旧方法です。問いは2つだけ。
- 回復できるか? ユーザーが入力を直せば成功するのか、しばらく待てば成功するのか、それとも手の打ちようがないのか。
- 想定内か? 起こると分かっていて設計に織り込んだ失敗か、それとも「来たらバグ」の異常事態か。
この2軸で並べると、扱い方が自然に決まります。
| 種類 | 例 | 回復可否 | 誰が直す | 扱い |
|---|---|---|---|---|
| 入力検証エラー | メール形式が不正、年齢が範囲外 | 回復可 | ユーザー | 400 で即返す |
| 外部API失敗(一時的) | 決済が 503、レート制限 | 回復可(待てば) | システム | リトライ |
| 外部API失敗(恒久的) | レスポンス形が想定と違う | 回復不可 | 開発者 | 失敗を記録して止める |
| ジョブ/バッチ失敗 | 夜間集計、CSV取込のコケ | 回復可(再実行) | システム/運用 | 再実行キュー・通知 |
| 想定外(バグ) | undefined 参照、nullクラッシュ | 回復不可 | 開発者 | 例外で落としてアラート |
ポイントは、message(人間向けの文章)だけで分岐しようとしないこと。文章は読みやすいけれど、プログラムが if (e.message.includes("invalid")) で判定し始めると、文言を変えた瞬間に壊れます。kind(種類)・code(機械可読なコード)・retryable(リトライ可否)・status(HTTPステータス)のような機械が読める情報を持たせる。これが設計の出発点です。
例外とResult型、どっちで返すか
ここがこの記事の核心です。「例外を使うべきか、Result型を使うべきか」は二択ではありません。役割が違うだけです。
例外(throw)は、想定外の事態を一気に上まで投げ上げる仕組みです。undefined.foo のようなバグ、設定ファイルの欠落、接続情報の誤り——「来たら設計が間違っている」たぐいの失敗は、握りつぶさず例外で落としてアラートに乗せたほうがいい。途中で雑に catch すると、バグが隠れて本番で静かに腐ります。
Result型は、想定内の失敗を「値」として返す入れ物です。成功なら値、失敗ならエラーを返す。型でいうとこんな形。
type Result<T> =
| { ok: true; value: T }
| { ok: false; error: AppError };
何がうれしいか。関数のシグネチャを見ただけで「この処理は失敗しうる」と分かることです。throw は型に出ません。callBillingApi(): Promise<{ customerId: string }> と書いてあっても、中で投げるかもしれない。一方 Promise<Result<{ customerId: string }>> なら、呼び出し側はコンパイラに「ok を分岐しろ」と強制されます。失敗の処理を忘れられないんです。
僕の使い分けはシンプルです。
- API境界・外部連携・ジョブ境界のように「失敗が想定内で、復旧判断を明示したい」場所 → Result型で返す。
- プログラム内部の不変条件が壊れた(来たらバグ)→ 例外で
throw。境界の一番外側でまとめて捕まえる。
「例外を全部禁止してResult型に統一」は、やりすぎるとコードが入れ子だらけになって読みにくくなります。想定内はResult、想定外は例外。この線引きが、現場でいちばん落ち着きます。
コピペで動くTypeScriptサンプル
説明より動かすのが早いです。外部ライブラリなしで動きます。Node.js 18以上を想定し、TypeScriptを直接実行するために tsx だけ使います。実際の外部APIには接続せず、テストしやすいように fetcher を注入しています。
npm install -D tsx typescript
npx tsx error-patterns-demo.ts
error-patterns-demo.ts として保存してください。
// 失敗の「種類」。名前ではなく回復方法に対応させる
type ErrorKind = "validation" | "external" | "job" | "unexpected";
// アプリ共通のエラー型。message(人間向け)とは別に機械可読な情報を持たせる
type AppError = {
kind: ErrorKind; // どの境界の失敗か
code: string; // EMAIL_INVALID のような機械可読コード
message: string; // ログ・調査用。ユーザーにそのまま見せない前提
retryable: boolean; // 時間を置けば回復しうるか
status: number; // 境界の外(HTTP)へ出すときのステータス
details?: Record<string, unknown>; // 調査に役立つ付帯情報
cause?: unknown; // 元の例外。原因チェーンを切らない
};
// 成功なら value、失敗なら error。型でどちらかに必ず分岐させる
type Result<T> =
| { ok: true; value: T }
| { ok: false; error: AppError };
const ok = <T>(value: T): Result<T> => ({ ok: true, value });
const fail = <T>(error: AppError): Result<T> => ({ ok: false, error });
// --- エラーを作る小さな工房。境界ごとに既定値を固定しておく ---
function validationError(code: string, message: string, details?: Record<string, unknown>): AppError {
// ユーザーが直せる失敗。同じ内容で再送しても無駄なので retryable は false
return { kind: "validation", code, message, retryable: false, status: 400, details };
}
function externalError(
code: string,
message: string,
retryable: boolean,
details?: Record<string, unknown>,
cause?: unknown,
): AppError {
// 一時的なら 503、恒久的(レスポンス形が違う等)なら 502 に寄せる
return { kind: "external", code, message, retryable, status: retryable ? 503 : 502, details, cause };
}
function jobError(code: string, message: string, details?: Record<string, unknown>, cause?: unknown): AppError {
// ジョブは基本やり直せる前提。再実行の判断材料を details に残す
return { kind: "job", code, message, retryable: true, status: 500, details, cause };
}
type CreateUserInput = {
email: string;
age: number;
plan: "free" | "pro";
};
// --- 境界1: 入力検証。受け取った直後に validation へ分類する ---
export function parseCreateUserInput(body: unknown): Result<CreateUserInput> {
if (typeof body !== "object" || body === null) {
return fail(validationError("BODY_REQUIRED", "リクエストボディがオブジェクトではありません"));
}
const record = body as Record<string, unknown>;
const email = record.email;
const age = record.age;
const plan = record.plan ?? "free";
if (typeof email !== "string" || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
return fail(validationError("EMAIL_INVALID", "メールアドレスの形式が不正です", { field: "email" }));
}
if (typeof age !== "number" || !Number.isInteger(age) || age < 13) {
return fail(validationError("AGE_INVALID", "年齢は13以上の整数で入力してください", { field: "age" }));
}
if (plan !== "free" && plan !== "pro") {
return fail(validationError("PLAN_INVALID", "プランは free か pro を指定してください", { field: "plan" }));
}
return ok({ email, age, plan });
}
type HttpResponse = { status: number; body: unknown };
// --- 境界の出口: Result を HTTP レスポンスへ変換。ここで内部情報を落とす ---
export function toHttpResponse<T>(result: Result<T>): HttpResponse {
if (result.ok) return { status: 200, body: { data: result.value } };
// ユーザーへ返すのは code/message/retryable/details だけ。cause(元例外)は出さない
const { code, message, retryable, details } = result.error;
return {
status: result.error.status,
body: { error: { code, message, retryable, details } },
};
}
type FetchLike = (url: string, init?: { signal?: AbortSignal }) => Promise<Response>;
// --- 境界2: 外部API。タイムアウトを必ず付け、リトライ可否を分けて返す ---
export async function callBillingApi(
userId: string,
fetcher: FetchLike = fetch,
): Promise<Result<{ customerId: string }>> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 3000); // 無期限待ちは枠を詰まらせる
try {
const response = await fetcher(`https://billing.example.test/customers/${userId}`, {
signal: controller.signal,
});
if (!response.ok) {
// 5xx は一時的とみなしリトライ可。4xx はこちら都合なのでリトライ不可
return fail(
externalError("BILLING_HTTP_ERROR", `課金APIが ${response.status} を返しました`, response.status >= 500, {
status: response.status,
}),
);
}
const payload = (await response.json()) as { customerId?: unknown };
if (typeof payload.customerId !== "string") {
// レスポンス形が想定外。何度叩いても直らないので retryable は false
return fail(externalError("BILLING_BAD_PAYLOAD", "課金APIの応答形式が不正です", false, { payload }));
}
return ok({ customerId: payload.customerId });
} catch (cause) {
// 通信そのものの失敗・タイムアウトは一時的とみなす
return fail(externalError("BILLING_UNREACHABLE", "課金APIに到達できませんでした", true, undefined, cause));
} finally {
clearTimeout(timer);
}
}
// --- 境界3: ジョブ。指定回数だけ再試行し、最後に job として残す ---
export async function runJob<T>(
name: string,
work: () => Promise<T>,
options: { retries: number; delayMs: number } = { retries: 2, delayMs: 100 },
): Promise<Result<T>> {
for (let attempt = 1; attempt <= options.retries + 1; attempt += 1) {
try {
return ok(await work());
} catch (cause) {
if (attempt <= options.retries) {
await sleep(options.delayMs);
continue;
}
// attempt を残すと「何回目で落ちたか」を後から追える
return fail(jobError("JOB_FAILED", `${name} が ${attempt} 回試行後に失敗しました`, { attempt }, cause));
}
}
return fail(jobError("JOB_FAILED", `${name} が想定外に失敗しました`));
}
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function demo() {
// 入力検証: 400 が返り、どのフィールドが悪いかまで分かる
console.log("validation:", toHttpResponse(parseCreateUserInput({ email: "bad", age: 10 })));
// 外部API: 503 を一時障害として retryable: true で返す
const billing = await callBillingApi("user_123", async () => new Response("Service unavailable", { status: 503 }));
console.log("external:", billing);
// ジョブ: 1回目は失敗、2回目で成功するシナリオ
let count = 0;
const job = await runJob("daily-report", async () => {
count += 1;
if (count < 2) throw new Error("temporary lock");
return { exportedRows: 42 };
});
console.log("job:", job);
}
demo().catch((error) => {
console.error(error);
process.exitCode = 1;
});
走らせると、validation は 400、external は retryable: true の 503、job は2回目で成功した結果が並びます。同じ AppError という土台の上で、境界ごとに扱いが違うのが見えるはずです。
失敗は「境界」で集約する。奥でバラバラに捕まえない
設計でいちばん効くのが、捕まえる場所を境界に寄せることです。境界とは、アプリの内側と外側が接する場所——API入力、外部サービス呼び出し、ジョブの実行単位、画面表示、DB保存です。
ありがちな失敗は、関数の奥のほうで try-catch をして、その場で console.log して null を返す、を繰り返すこと。これをやると、失敗が点在します。同じ「決済が落ちた」でも、捕まえる場所によってログの形もステータスも変わる。調査するとき、どこを見ればいいのか分からなくなります。
代わりに、深いところは素直に失敗を投げる/Resultで返すだけにして、境界の一番外側で一度だけまとめて処理します。Expressなら、エラーハンドリング用のミドルウェアを一番最後に置くのが定石です。
import express, { type Request, type Response, type NextFunction } from "express";
const app = express();
app.use(express.json());
app.post("/users", (req: Request, res: Response) => {
const parsed = parseCreateUserInput(req.body); // 想定内の失敗は Result で受ける
const result = toHttpResponse(parsed);
res.status(result.status).json(result.body);
});
// 一番最後に置く「集約点」。想定外(throw)はここで一度だけ捕まえる
app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
// ここでだけ unexpected を作り、ログには cause を残す
console.error("[unexpected]", err); // 実運用は構造化ログへ
res.status(500).json({ error: { code: "INTERNAL", message: "サーバー内部でエラーが発生しました", retryable: false } });
});
この形にすると、想定内の失敗(Result)と想定外の失敗(throw)の出口が1か所ずつに揃います。「ユーザーに見せる文言」と「ログに残す情報」を切り替えるのも、この集約点だけ直せば済む。Express公式のError handlingに、ミドルウェアを最後に置く理由が書かれています。
ユーザー向けメッセージと内部ログを分ける
AppError に message と cause の両方を持たせているのには理由があります。読む相手が違うからです。
ユーザー(やAPI呼び出し側)に返すのは、code・短い message・retryable くらいで十分。逆に、スタックトレース、SQL文、環境変数名、APIキーの断片、内部のテーブル名をレスポンスに混ぜるのは事故です。攻撃者へのヒントになりますし、サポートに「ECONNREFUSED at pg.connect って出ました」と言われても利用者は困るだけ。
一方、ログには逆に全部残す。code・kind・retryable・attempt・cause(元の例外)。これがないと、あとで原因を追えません。先ほどの toHttpResponse が cause をレスポンスに含めていないのは、この分離を型レベルで効かせるためです。
// 出口で2系統に分ける: ログには全部、レスポンスには最小限
function emit(error: AppError, res: { status: number; body: unknown }) {
// ログ: 調査に必要な情報を構造化して残す(cause も含める)
logger.error({ code: error.code, kind: error.kind, retryable: error.retryable, cause: error.cause });
// レスポンス: ユーザーに見せてよい情報だけ
return { status: error.status, body: { error: { code: error.code, message: error.message, retryable: error.retryable } } };
}
ログ設計そのものを詰めたい人は、構造化ログとPIIマスクを扱った「ログ入れて」が本番で死ぬ理由が地続きです。出てきたログの読み方はエラーメッセージの読み方に寄せると、調査の手順までつながります。
リトライは「時間で回復する失敗」だけに絞る
runJob にリトライを入れましたが、リトライは万能薬ではありません。入れる対象を間違えると、障害対応のつもりが事故の増幅になります。
リトライしていいのは、時間を置けば回復しうる失敗だけ。具体的には、一時的なネットワーク断、503、レート制限(429)、DBのロック競合。retryable: true を立てているのはこの系統です。
逆に、リトライしてはいけないのがこちら。
- バリデーションエラー:入力が同じなら、何度送っても結果は同じ。
- レスポンス形が想定外:相手の仕様変更かバグ。叩き続けても直らず、ログとキューを汚すだけ。
- 副作用が冪等でない処理:同じCSVをもう一度取り込んで二重登録しないか、メールを二重送信しないか、課金を再実行して二重請求しないか。ここを確かめずにリトライを入れると、リトライのたびに被害が増えます。
実装上のコツは、リトライ間隔を少しずつ広げる指数バックオフにすること。即座に連打すると、落ちている相手にとどめを刺します。delayMs を 100 → 200 → 400 のように倍々で伸ばすだけでも、相手に回復の時間を渡せます。
Claude Codeに「設計」でレビューさせる
このパターンの良いところは、Claude Codeへの依頼が短く具体的になることです。「このエラーをいい感じに直して」だと一般論が返りますが、設計の語彙があると指示がぶれません。実装後はこのプロンプトでレビューさせています。
このPRのエラー処理を「設計」の観点でレビューしてください。
1. 失敗を入力検証/外部API/ジョブ/想定外のどれかに分類できているか
2. 想定内の失敗をResult型、想定外(バグ)を例外、で使い分けているか
3. catch を境界の外側に集約しているか(奥でnullを返して握りつぶしていないか)
4. ユーザー向けmessageにスタックトレース・SQL・秘密情報が漏れていないか
5. retryable を立てているのが「時間で回復する失敗」だけになっているか
6. リトライ対象の副作用が冪等か(二重登録・二重課金が起きないか)
不足は、最小差分の修正案と失敗系テストを3つ出してください。
ここで効くのが、プロジェクトの AGENTS.md や CLAUDE.md に「境界で分類する/想定内はResult、想定外は例外」と一行書いておくこと。ルールがないと、別の人がすぐに throw new Error("failed") へ戻してしまいます。型の絞り込みを使うなら、公式のTypeScript Narrowingが判別可能なUnionの根拠になります。
僕がやらかした失敗3つ
正直に書きます。最初の設計は穴だらけでした。
ひとつ目は、catch (e) { return null; } で失敗を消したこと。一見きれいに動くんですが、障害が起きたとき手がかりがゼロでした。何が落ちたのか、cause すら残っていない。今は最低でも code・kind・cause のどれかは必ず残します。
ふたつ目は、例外とResult型を混ぜて使ったこと。同じ層で、ある関数は throw、別の関数は Result を返していて、呼び出し側が毎回「これはどっち?」と考える羽目になりました。層ごとに「ここはResultで返す」と決めてから、迷いが消えました。
みっつ目は、全部にリトライを入れたこと。良かれと思ってバリデーション失敗まで3回再送する作りにしたら、ユーザーの入力ミスで外部APIを3倍叩いていました。レート制限に引っかかって、まともなリクエストまで弾かれる始末。リトライは retryable が立っているものだけ、に直しました。
よくある質問
Q. 例外とResult型、結局どっちを使えばいいですか? 想定内の失敗(入力エラー、外部API失敗など、設計に織り込んだもの)はResult型、想定外(来たらバグ)は例外で使い分けます。全部Result型にすると入れ子が深くなり、全部例外にすると型に失敗が現れません。
Q. 小さなスクリプトでもResult型を導入すべき?
いりません。使い捨てのスクリプトや、失敗したら止まればいいCLIなら throw で十分です。Result型が効くのは、複数の境界があって「どこで何の失敗を返すか」を明示したいアプリです。
Q. エラーコード(EMAIL_INVALID 等)はどう決める?
境界_対象_理由 のような粒度で、英大文字とアンダースコアに統一すると検索しやすいです。大事なのは命名規則より、message(文章)ではなく code でプログラムが分岐できる状態にすること。
Q. リトライの回数と間隔の目安は? 外部APIなら2〜3回、間隔は指数バックオフ(例: 100ms → 200ms → 400ms)が無難です。回数を増やすほどユーザーの待ち時間と相手への負荷が伸びるので、画面同期処理は少なめ、バッチは多めが目安です。
Q. Reactの画面で出たエラーはこの設計で捕まえられますか?
レンダリング中のエラーは try-catch では捕まりません。Error Boundaryという別の仕組みが必要です。設計の考え方は同じ(境界で捕まえてユーザー向けに見せる)なので、Error Boundaryの実装を参照してください。
この記事の内容を実際に試した結果
冒頭の「全部 500」事故のあと、僕は入力検証・外部API・バッチ失敗を、ぜんぶ同じ AppError の形に寄せました。変わったのは、Claude Codeへの依頼です。前は「このエラーをいい感じに直して」と書いていたのが、今は「validationは400、externalはretryableを見て再試行、jobはattemptを残す」と一行で渡せる。
レビューで見る場所も、レスポンス・ログ・テストの3点に絞れました。例外とResult型の線引きを決めただけで、「この失敗はどこで捕まえるんだっけ」と悩む時間がほぼ消えた。まだ万能ではありません。でも、500 だらけで原因が追えなかった頃と比べれば、運用で追えるコードに確実に近づいています。
賢いAIに丸投げするより、失敗の置き場所を先に決める。遠回りに見えて、これがいちばん速い、というのが今の実感です。エラー処理のレビュー観点や AGENTS.md の書き方までまとめて整えたい人は教材一覧が近道です。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
Claude Codeのチーム利用でコストが読めない時に作る予算ログ
チーム導入前に、誰が何に使い、どの成果が出たかを見える化する予算ログの作り方。
コミット前の3分チェック: Claude Codeが触った範囲を確認してから確定する
Claude Codeが勝手に広げた変更を、コミット前に3分で見抜く確認手順。差分の範囲、検証ログ、ステージするファイルの絞り込みを順番に解説します。
Claude Codeをチーム導入する前に作る「リスク台帳」の中身
Claude Codeを個人実験で終わらせずチーム導入するための、権限・CI・公開の事故を防ぐリスク台帳の作り方を実例とコードで解説します。