エラー設計 — エラーコード体系・ユーザー通知・ログ戦略
この章の目次開く
エラーは「起きないように頑張る」ものではなく、必ず起きる前提で扱いを設計するものです。ネットワークは切れ、外部APIは落ち、ユーザーは想定外の入力をします。
エラー設計をせずに実装を始めると、「とりあえず try-catch で握りつぶす」「どこでも 500 を返す」「ユーザーにスタックトレースが見える」といった場当たり的な処理がコードベース中に散らばります。この章では、エラーの分類・エラーコード体系・ユーザー通知とログの分離・非同期処理のエラー戦略を順に設計していきます。
学習者エラー処理って try-catch で囲んでおけばいいんじゃないの?設計するようなことなの?
先生try-catch は「捕まえる道具」でしかない。捕まえた後に「誰に・何を・どう伝えるか」——ユーザーへの表示、開発者へのログ、呼び出し元への通知——を決めるのがエラー設計だよ。ここを決めずに書くと、エラーのたびに各自がバラバラの処理を書くことになる。
エラーを分類する — すべてのエラーは同じではない
エラー設計の出発点は分類です。性質の違うエラーを同じように扱うことが、多くの混乱の原因になります。
予期するエラーと予期しないエラー
| 分類 | 例 | 扱い方 |
|---|---|---|
| 予期するエラー(業務エラー) | 入力値の不正、在庫切れ、権限不足、重複登録 | 正常系の一部として設計する。ユーザーに「どうすれば解決するか」を伝える |
| 予期しないエラー(システムエラー) | DB接続断、外部APIのタイムアウト、バグによる例外 | ユーザーには汎用メッセージを返し、詳細はログに記録して開発者が対応する |
「在庫切れ」をシステム障害のように 500 で返すのも、「DB接続断」をユーザーの操作ミスのように扱うのも、どちらも分類の失敗です。
エラーコード体系の設計
HTTPステータスコード(API設計の基本で扱いました)は「エラーの大分類」しか表現できません。400 Bad Request だけでは、フロントエンドは「どの項目が」「なぜ」ダメなのかを判別できません。
そこで、アプリケーション独自のエラーコードを設計します。
{
"error": {
"code": "ORDER_STOCK_SHORTAGE",
"message": "在庫が不足しています",
"details": [{ "productId": 42, "requested": 3, "available": 1 }]
}
}エラーコードの設計ルール
- 機械可読な文字列にする —
E1042のような番号よりORDER_STOCK_SHORTAGEのような英語の定数名の方が、コード検索できてログも読みやすい ドメイン_内容の形式で命名を統一する —USER_NOT_FOUND、AUTH_TOKEN_EXPIRED、ORDER_STOCK_SHORTAGEのように、どの機能領域のエラーかが先頭で分かるようにする- 一覧をドキュメント化する — エラーコードはAPIの契約の一部。フロントエンドはコードを見て分岐するため、勝手に変更・削除しない
- HTTPステータスとの対応を決めておく — 「業務エラーは
400か422、認証は401、認可は403」のような対応表を作り、個人の裁量で選ばせない
TypeScriptでは、エラーコードを持つ独自のエラークラスを定義しておくと、throwする側と処理する側の契約が明確になります。
// アプリ全体で使う基底エラークラス
class AppError extends Error {
constructor(
public readonly code: string, // 機械可読なエラーコード
public readonly statusCode: number, // 対応するHTTPステータス
message: string // 開発者向けの説明(ログ用)
) {
super(message);
}
}
class StockShortageError extends AppError {
constructor(productId: number) {
super('ORDER_STOCK_SHORTAGE', 409, `商品${productId}の在庫が不足`);
}
}
// 使う側: 業務ロジックは「何が起きたか」をthrowするだけ
if (stock < requested) {
throw new StockShortageError(product.id);
}ユーザー向けメッセージとログの分離
エラーが起きたとき、伝えるべき相手は2人います。ユーザーと開発者です。この2人は必要とする情報がまったく違います。
| 観点 | ユーザー向けメッセージ | ログ(開発者向け) |
|---|---|---|
| 目的 | 次に何をすればよいかを伝える | 原因を特定して修正する |
| 内容 | 平易な言葉。解決方法・問い合わせ先 | スタックトレース、リクエスト内容、変数の値 |
| 例 | 「時間をおいて再度お試しください」 | ECONNREFUSED db:3306 at OrderRepository.insert |

やってはいけないのは、この2つを混ぜることです。
❌ ユーザーに内部情報を見せる
「Error: connect ECONNREFUSED 10.0.1.5:3306」
→ ユーザーは意味が分からず不安になるだけ。さらに内部のIPアドレスや
使用技術が漏れ、攻撃者へのヒントになる(セキュリティ問題)
❌ ログに詳細がない
logger.error('エラーが発生しました')
→ 開発者が調査できない。「どのリクエストで・誰が・何をして」が必要
先ほどの AppError を使うと、この分離をエラーハンドリングの共通処理(Expressならエラーハンドリングミドルウェア)で一元化できます。
// エラー処理の一元化: 個々のルートハンドラにはtry-catchを書かせない
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
if (err instanceof AppError) {
// 予期するエラー: コードに応じたメッセージをユーザーへ
logger.warn({ code: err.code, path: req.path, message: err.message });
return res.status(err.statusCode).json({
error: { code: err.code, message: userMessageFor(err.code) },
});
}
// 予期しないエラー: 詳細はログのみ。ユーザーには汎用メッセージ
logger.error({ path: req.path, stack: err.stack, requestId: req.id });
return res.status(500).json({
error: { code: 'INTERNAL_ERROR', message: '一時的な問題が発生しました。時間をおいて再度お試しください。' },
});
});ログ戦略 — 何を・どのレベルで記録するか
ログは「書けば書くほど良い」ものではありません。ノイズが多いログは、障害時に本当に必要な情報を埋もれさせます。設計段階で決めるのはレベルの使い分けと必ず含める情報です。
ログレベルの使い分け
| レベル | 用途 | 例 |
|---|---|---|
error | 開発者の対応が必要な問題 | 未捕捉の例外、DB接続断、外部API連続失敗 |
warn | 即対応は不要だが注視すべき事象 | 業務エラーの多発、リトライ発生、非推奨機能の使用 |
info | 正常系の重要イベント | 起動・停止、決済完了、外部連携の実行 |
debug | 開発時の調査用(本番では通常出さない) | 関数の入出力、SQL、中間状態 |
構造化ログと相関ID
ログは文章ではなく、機械で検索・集計できる**JSON形式(構造化ログ)**で出すのが現在の主流です。そして1つのリクエストに関するログを追跡できるよう、**リクエストID(相関ID)**をすべてのログに含めます。
{
"level": "error",
"time": "2026-07-04T10:23:45Z",
"requestId": "req_8f3a1c",
"userId": 123,
"code": "ORDER_STOCK_SHORTAGE",
"path": "/api/orders",
"message": "商品42の在庫が不足"
}障害調査は「ユーザーからの問い合わせ → requestIdの特定 → そのリクエストの全ログを時系列で追う」という流れになります。requestIdがないと、大量のログから該当リクエストの記録を推測で探すことになります。
非同期処理のエラー戦略
学習者try-catch で囲んでいるのにエラーが捕まえられないことがあって…。非同期処理だと何かが違うの?
先生それは典型的なハマりどころ。try-catch が捕まえられるのは「同じ実行の流れの中で」投げられたエラーだけ。await を忘れたPromiseの失敗は、tryブロックを抜けた後に起きるから捕まえられないんだ。
// ❌ awaitがないため、sendMail内のエラーはこのtry-catchを素通りする
try {
sendMail(user.email); // Promiseを待っていない
} catch (e) {
// ここには到達しない
}
// ⭕ awaitすればcatchできる
try {
await sendMail(user.email);
} catch (e) {
logger.warn({ code: 'MAIL_SEND_FAILED', userId: user.id });
}「待たない処理」のエラーはどこで受けるか
一方で、意図的に完了を待たない処理(メール送信、通知、バッチ処理など)もあります。この場合は「エラーをどこで受けて、どうリカバリするか」を設計で決めておきます。
- 失敗しても本処理を止めないなら、その処理の中で必ずcatchしてログに残す(放置するとプロセスを落とす未処理のPromise拒否になる)
- 失敗したら後で再実行したいなら、ジョブキューに積んでリトライする。リトライ間隔は徐々に延ばし(エクスポネンシャルバックオフ)、上限回数を超えたものは失敗キュー(デッドレターキュー)に隔離して人が調査する
- リトライしてよいのは、再実行しても副作用が重複しない処理だけ。決済のような処理を単純リトライすると二重課金の危険がある(べき等性の担保が必要)
よくあるハマりどころ
エラーの握りつぶし
// ❌ 最悪のパターン: エラーが起きた事実ごと消える
try {
await updateUserProfile(data);
} catch (e) {
// 何もしない
}「とりあえず落ちないように」と空のcatchを書くと、障害が静かに進行します。ユーザーは「保存したはずのデータが消えている」と後から気づき、開発者には手がかりが何も残りません。捕まえたら、最低限ログに残す。対処できないなら、握りつぶさずに上位へ投げ直します。
すべてを500で返す
分類を設計していないプロジェクトでは、バリデーションエラーも在庫切れも 500 Internal Server Error で返されがちです。フロントエンドは原因を判別できず「エラーが発生しました」としか表示できなくなり、監視上も「本物の障害」と「日常的な業務エラー」が区別できなくなります。
エラーメッセージの実装依存
ユーザー向けメッセージにORMや外部ライブラリのエラー文言をそのまま流すと、ライブラリの更新でメッセージが変わり、フロントエンドの文言分岐が壊れます。フロントエンドが分岐に使ってよいのはエラーコードだけ、と決めておくのが安全です。
ちゃんと使うためのポイント
- エラー設計とは「誰に・何を・どう伝えるか」を決めること——try-catchは道具にすぎない
- まず「予期するエラー(ユーザーが解決できる)」と「予期しないエラー(開発者しか解決できない)」に分類する
- エラーコードは機械可読な文字列(
ドメイン_内容)で統一し、APIの契約としてドキュメント化する - ユーザーには解決方法を、ログには原因特定の材料を——内部情報をユーザーに見せない
- ログは構造化(JSON)+ リクエストIDで追跡可能にする。個人情報・秘密情報は出さない
- 非同期処理は「エラーをどこで受けるか」を必ず決める。握りつぶしと未処理のPromise拒否を作らない
エラー設計の中でも「入力値の不正」は、発生頻度が圧倒的に高い予期するエラーです。次の章では、これを多層で防ぐバリデーション設計を解説します。
参考リンク
- Error - MDN — Errorオブジェクトとカスタムエラークラスの作り方
- try...catch - MDN — 例外捕捉の基本構文リファレンス
- RFC 9457: Problem Details for HTTP APIs — HTTP APIのエラーレスポンス標準仕様(英語)