JavaScript/TypeScriptエラー処理完全実践ガイド〜try/catch・カスタムError・Result型・観測性〜【2026年版】

エラー処理は JavaScript / TypeScript アプリケーションの品質と信頼性を左右する最重要トピックです。にもかかわらず、多くの実装は try { ... } catch (e) {} で握り潰す、throw "文字列" で投げる、any として扱う、といったアンチパターンに陥りがちです。本記事は ECMAScript 2025 / Node.js 22 LTS / TypeScript 5.x / React 19 準拠で、コピペで動く 40 以上のエラー処理コードサンプルを通して、try/catch/finally の基礎 → Error クラス階層 → カスタム Error の正しい設計 → Error.cause による原因の連鎖 → AggregateError による複合エラー → 型ガード(e: unknown)→ Result 型 / neverthrow / fp-ts Either によるエラーの値化 → React Error Boundary・Express ミドルウェア・uncaughtException / unhandledrejection による横断的捕捉 → Sentry / OpenTelemetry による観測性まで、現場で実用に耐えるエラー処理の全体像を体系的に整理します。

本記事は Promise 完全実践ガイド(非同期プリミティブの観点)、async/await 完全実践ガイド(高位抽象の観点)、TypeScript 型ガード完全ガイド(型レベルの観点)と相互補完的に読むことで、同期・非同期・型・観測性を一気通貫で押さえることができます。

  1. 1. エラー処理の基本構文 – try / catch / finally / throw
    1. 1.1 try / catch の最小構文
    2. 1.2 finally の評価順と落とし穴
    3. 1.3 throw 文の正しい使い方
    4. 1.4 try のネストと外側への再 throw
  2. 2. Error クラス階層と組み込みエラー
    1. 2.1 標準 Error コンストラクタ
    2. 2.2 7 種類の組み込みサブクラス
    3. 2.3 instanceof による分岐
    4. 2.4 instanceof が壊れるケース(realm 跨ぎ)
  3. 3. カスタム Error クラスの設計
    1. 3.1 最小カスタム Error
    2. 3.2 業務メタデータを持たせる
    3. 3.3 TypeScript でのカスタム Error
    4. 3.4 prototype チェーンの落とし穴(target ES5)
  4. 4. Error.cause – 原因の連鎖(ES2022)
    1. 4.1 cause の基本
    2. 4.2 多層 cause チェーン
    3. 4.3 TypeScript での cause 型付け
  5. 5. AggregateError – 複数エラーの集約
    1. 5.1 Promise.any での AggregateError
    2. 5.2 自前で AggregateError を投げる
    3. 5.3 Promise.allSettled で代用
  6. 6. 非同期処理のエラー処理
    1. 6.1 Promise.catch の基本
    2. 6.2 async/await + try/catch
    3. 6.3 await を忘れた async 関数の罠
    4. 6.4 並列処理での部分失敗
  7. 7. 横断的エラー捕捉(ブラウザ / Node.js)
    1. 7.1 ブラウザ: window.onerror
    2. 7.2 ブラウザ: unhandledrejection
    3. 7.3 Node.js: uncaughtException / unhandledRejection
    4. 7.4 graceful shutdown
  8. 8. TypeScript でのエラー処理 – catch (e: unknown)
    1. 8.1 unknown の正しい絞り込み
    2. 8.2 型ガード関数(isHttpError)
    3. 8.3 エラー正規化ヘルパー
    4. 8.4 noImplicitAny との関係
  9. 9. Result 型 – エラーを値として扱う
    1. 9.1 Result 型の自前実装
    2. 9.2 Result 型での fetch ラッパー
    3. 9.3 neverthrow ライブラリ
    4. 9.4 neverthrow + ResultAsync
    5. 9.5 fp-ts Either
    6. 9.6 zod safeParse の Result
  10. 10. API / HTTP エラー設計
    1. 10.1 4xx と 5xx の責務分離
    2. 10.2 リトライ戦略(指数バックオフ)
    3. 10.3 AbortController によるキャンセル
    4. 10.4 RFC 7807 problem+json
  11. 11. React のエラー処理
    1. 11.1 クラスコンポーネントの Error Boundary
    2. 11.2 react-error-boundary パッケージ
    3. 11.3 React 19 の onCaughtError / onUncaughtError
    4. 11.4 TanStack Query の throwOnError
    5. 11.5 イベントハンドラ内のエラー
  12. 12. Express / Fastify でのエラー処理
    1. 12.1 Express エラーハンドラの定義
    2. 12.2 async wrapper でtry/catch を省く
    3. 12.3 Fastify の setErrorHandler
  13. 13. 観測性 – Sentry / OpenTelemetry / 構造化ログ
    1. 13.1 Sentry 連携
    2. 13.2 React の Sentry Error Boundary
    3. 13.3 OpenTelemetry の trace と紐付け
    4. 13.4 構造化ログ(pino / winston)
    5. 13.5 ログレベル設計
  14. 14. アンチパターン総まとめと Lint 設定
    1. 14.1 やってはいけない 10 のパターン
    2. 14.2 ESLint ルール設定
    3. 14.3 tsconfig.json の安全設定
    4. 14.4 エラー処理を強制する設計指針
  15. 15. まとめ – 現代 JS / TS のエラー処理 5 原則

1. エラー処理の基本構文 – try / catch / finally / throw

JavaScript のエラー処理は try / catch / finallythrow の 4 キーワードから始まります。基礎構文を曖昧にしたまま応用に進むと、finally の評価順や return との相互作用で必ず詰まります。

1.1 try / catch の最小構文

try ブロック内で発生した例外は、対応する catch 節に捕捉されます。catch の引数は省略可能(ES2019 以降)で、捕捉した値を使わない場合は catch {} と書けます。

// 基本構文
try {
  const data = JSON.parse("invalid json");
  console.log(data);
} catch (error) {
  console.error("パース失敗:", error);
}

// 引数省略(ES2019: optional catch binding)
try {
  riskyOperation();
} catch {
  console.log("失敗したが詳細不要");
}

1.2 finally の評価順と落とし穴

finally必ず実行されるブロックで、リソースの解放(ファイルクローズ、DB コネクション返却、ローディング状態解除など)に使います。注意点は finally 内の returntry / catch の戻り値を上書きしてしまうことです。

// finally は必ず実行される
function safeRead() {
  try {
    return "正常値";
  } catch (e) {
    return "エラー値";
  } finally {
    console.log("クリーンアップ"); // 常に実行
  }
}
console.log(safeRead()); // "クリーンアップ" → "正常値"

// アンチパターン: finally で return すると上書きされる
function badPattern() {
  try {
    return "A";
  } finally {
    return "B"; // ← これが返ってしまう
  }
}
console.log(badPattern()); // "B"

1.3 throw 文の正しい使い方

throw必ず Error インスタンスを投げます。文字列やオブジェクトリテラルも投げられますが、スタックトレースが失われ、後段の型判定が破綻するため禁止すべきです(後述の Lint 設定で no-throw-literal を有効化)。

// ✅ 正しい: Error インスタンスを投げる
function divide(a, b) {
  if (b === 0) {
    throw new Error("Division by zero");
  }
  return a / b;
}

// ❌ アンチパターン: 文字列を投げる
function badDivide(a, b) {
  if (b === 0) {
    throw "Division by zero"; // スタックトレース無し
  }
  return a / b;
}

// ❌ アンチパターン: オブジェクトリテラルを投げる
function worseDivide(a, b) {
  if (b === 0) {
    throw { code: "DIV_ZERO", message: "..." }; // instanceof Error が false
  }
  return a / b;
}

1.4 try のネストと外側への再 throw

キャッチした例外をそのまま再度 throwすることで、外側の処理に伝播させられます。途中でログだけ取って外側に投げ直すパターンは、ログ重複を避けつつ責務を分離するのに便利です。

// 内側でログを残して外側に伝播
function inner() {
  throw new Error("内側エラー");
}

function outer() {
  try {
    inner();
  } catch (e) {
    console.error("[outer] caught:", e.message);
    throw e; // 再 throw
  }
}

try {
  outer();
} catch (e) {
  console.error("[top] 最終捕捉:", e.message);
}

2. Error クラス階層と組み込みエラー

JavaScript には Error をルートとする組み込みエラー階層があり、何が起きたかを型で表現する一次手段になっています。まず標準を把握してから、カスタムエラーを設計します。

2.1 標準 Error コンストラクタ

Errorname / message / stack の 3 プロパティを持ち、new Error(message) で生成します。name はサブクラスでオーバーライドして識別子として使います。

const e = new Error("何かが壊れた");
console.log(e.name);    // "Error"
console.log(e.message); // "何かが壊れた"
console.log(e.stack);   // "Error: 何かが壊れたn  at ..."

// toString は "name: message" を返す
console.log(`${e}`); // "Error: 何かが壊れた"

2.2 7 種類の組み込みサブクラス

仕様で定義された組み込みエラーは TypeError / RangeError / SyntaxError / ReferenceError / URIError / EvalError / AggregateError の 7 つです。それぞれ発生条件が決まっているため、自前で投げる際も意味を合わせます。

// TypeError: 型が違う
try { null.foo; } catch (e) { console.log(e instanceof TypeError); } // true

// RangeError: 範囲外
try { new Array(-1); } catch (e) { console.log(e instanceof RangeError); } // true

// ReferenceError: 未定義参照
try { undefinedVar; } catch (e) { console.log(e instanceof ReferenceError); } // true

// SyntaxError: 構文エラー(eval や JSON.parse で発生)
try { JSON.parse("{"); } catch (e) { console.log(e instanceof SyntaxError); } // true

// URIError: encodeURIComponent などの引数不正
try { decodeURIComponent("%"); } catch (e) { console.log(e instanceof URIError); } // true

2.3 instanceof による分岐

捕捉したエラーが何の種類かは instanceof で判定します。具体的なサブクラスから順に判定するのが鉄則で、最後のフォールバックとして instanceof Error を置きます。

function classify(error) {
  if (error instanceof TypeError) {
    return "型エラー";
  } else if (error instanceof RangeError) {
    return "範囲エラー";
  } else if (error instanceof SyntaxError) {
    return "構文エラー";
  } else if (error instanceof Error) {
    return "その他の Error";
  }
  return "Error でない何か"; // 文字列を投げられたケース等
}

try {
  null.foo;
} catch (e) {
  console.log(classify(e)); // "型エラー"
}

2.4 instanceof が壊れるケース(realm 跨ぎ)

iframe や worker など別 realm から渡された Error は、ローカルの Error コンストラクタとは別物のため instanceof Errorfalse になります。クロス realm では Object.prototype.toString.call を使うのが安全です。

// 別 realm の Error は instanceof で判定不能
function isErrorLike(value) {
  return Object.prototype.toString.call(value) === "[object Error]";
}

// 例: iframe から Error を投げられた場合
// e instanceof Error → false
// isErrorLike(e)      → true

3. カスタム Error クラスの設計

業務ドメイン固有のエラー(認証失敗、バリデーション失敗、外部 API 失敗など)は、Error を継承したカスタムクラスとして設計します。これによりキャッチ側で instanceof で分岐でき、ログや HTTP ステータスへのマッピングが型安全になります。

3.1 最小カスタム Error

カスタム Error の最小実装は super(message) を呼び、this.name をクラス名と一致させることです。name を設定しないと "Error" のままになり、ログで判別できません。

class NotFoundError extends Error {
  constructor(message) {
    super(message);
    this.name = "NotFoundError";
  }
}

const e = new NotFoundError("ユーザーが見つかりません");
console.log(e.name);                       // "NotFoundError"
console.log(e instanceof NotFoundError);   // true
console.log(e instanceof Error);           // true

3.2 業務メタデータを持たせる

HTTP ステータス、エラーコード、リクエスト ID などをプロパティとして保持させることで、後段のミドルウェアが構造化ログとして扱えます。

class HttpError extends Error {
  constructor(message, statusCode, code) {
    super(message);
    this.name = "HttpError";
    this.statusCode = statusCode;
    this.code = code;
  }
}

class ValidationError extends HttpError {
  constructor(message, fields) {
    super(message, 400, "VALIDATION_FAILED");
    this.name = "ValidationError";
    this.fields = fields;
  }
}

const e = new ValidationError("入力不正", { email: "必須" });
console.log(e.statusCode); // 400
console.log(e.fields);     // { email: "必須" }

3.3 TypeScript でのカスタム Error

TypeScript ではプロパティ型を明示し、readonly で不変性を担保します。nameリテラル型にすることで、判別ユニオン的に扱えます。

class HttpError extends Error {
  public readonly name = "HttpError" as const;
  constructor(
    message: string,
    public readonly statusCode: number,
    public readonly code: string,
  ) {
    super(message);
  }
}

class NotFoundError extends HttpError {
  public readonly name = "NotFoundError" as const;
  constructor(resource: string) {
    super(`${resource} not found`, 404, "NOT_FOUND");
  }
}

function handle(e: HttpError) {
  if (e.name === "NotFoundError") {
    // ここでは e.name で narrowing できる
  }
}

3.4 prototype チェーンの落とし穴(target ES5)

TypeScript の target: "ES5" でビルドすると、extends Error のサブクラスで instanceof が壊れる古典的バグがあります。回避策は Object.setPrototypeOfsuper 直後に呼ぶことです。target: "ES2015" 以降では不要です。

// ES5 ターゲットでの回避策
class LegacyError extends Error {
  constructor(message) {
    super(message);
    Object.setPrototypeOf(this, LegacyError.prototype);
    this.name = "LegacyError";
  }
}

4. Error.cause – 原因の連鎖(ES2022)

ES2022 で標準化された Error の第 2 引数 { cause } は、低レイヤエラーを失わず高レイヤエラーで包み直すための公式 API です。Java の InitCause、Rust の source() に相当する機能で、これにより 「ラップしてもスタックトレースの根本原因が辿れる」 状態を作れます。

4.1 cause の基本

低レイヤで発生した SyntaxError をドメインエラーで包み、cause に元の例外を入れます。捕捉側は e.cause を辿ることで根本原因に到達できます。

function loadConfig(text) {
  try {
    return JSON.parse(text);
  } catch (e) {
    throw new Error("設定ファイルのパースに失敗", { cause: e });
  }
}

try {
  loadConfig("invalid");
} catch (e) {
  console.error(e.message);       // "設定ファイルのパースに失敗"
  console.error(e.cause.message); // "Unexpected token 'i'..."
}

4.2 多層 cause チェーン

マイクロサービス間や複数ライブラリを横断するシステムでは、cause が3 段以上ネストすることがあります。再帰でチェーンを表示するヘルパーを用意しておくと便利です。

function printChain(error, depth = 0) {
  const indent = "  ".repeat(depth);
  console.error(`${indent}- ${error.name}: ${error.message}`);
  if (error.cause) {
    printChain(error.cause, depth + 1);
  }
}

try {
  try {
    try {
      throw new Error("DB接続失敗");
    } catch (e) {
      throw new Error("ユーザー取得失敗", { cause: e });
    }
  } catch (e) {
    throw new Error("ログイン処理失敗", { cause: e });
  }
} catch (e) {
  printChain(e);
  // - Error: ログイン処理失敗
  //   - Error: ユーザー取得失敗
  //     - Error: DB接続失敗
}

4.3 TypeScript での cause 型付け

標準 Errorcause 型は unknown です。型ガード関数を作って安全に展開します。

function hasCause(e: unknown): e is Error & { cause: Error } {
  return e instanceof Error && e.cause instanceof Error;
}

try {
  loadConfig("...");
} catch (e) {
  if (hasCause(e)) {
    console.error("根本原因:", e.cause.message);
  }
}

5. AggregateError – 複数エラーの集約

AggregateError は ES2021 で導入された複数エラーを 1 つにまとめる標準クラスです。主な発生源は Promise.any の全 reject 時で、自前のバリデーションやバッチ処理でも積極的に活用できます。

5.1 Promise.any での AggregateError

Promise.any最初に成功した結果を返しますが、全部 reject されると AggregateError を投げます。errors プロパティに個別エラーが配列で入ります。

const tasks = [
  Promise.reject(new Error("A 失敗")),
  Promise.reject(new Error("B 失敗")),
  Promise.reject(new Error("C 失敗")),
];

try {
  await Promise.any(tasks);
} catch (e) {
  console.log(e instanceof AggregateError); // true
  console.log(e.errors.length);             // 3
  e.errors.forEach((sub) => console.log(sub.message));
}

5.2 自前で AggregateError を投げる

バリデーションで複数フィールドのエラーを 1 度に返したい場合は、AggregateError を直接投げます。

function validateUser(user) {
  const errors = [];
  if (!user.email) errors.push(new ValidationError("email 必須"));
  if (!user.name)  errors.push(new ValidationError("name 必須"));
  if (user.age < 0) errors.push(new ValidationError("age は非負"));

  if (errors.length > 0) {
    throw new AggregateError(errors, "ユーザー検証失敗");
  }
}

try {
  validateUser({ age: -1 });
} catch (e) {
  if (e instanceof AggregateError) {
    e.errors.forEach((sub) => console.error(sub.message));
  }
}

5.3 Promise.allSettled で代用

全部の結果を見たい場合は Promise.allSettled の方が便利です。reject 側だけ抽出して AggregateError に詰め直すパターンも実用的です。

async function runAll(tasks) {
  const results = await Promise.allSettled(tasks);
  const failures = results
    .filter((r) => r.status === "rejected")
    .map((r) => r.reason);

  if (failures.length > 0) {
    throw new AggregateError(failures, `${failures.length} 件失敗`);
  }
  return results.map((r) => r.value);
}

6. 非同期処理のエラー処理

非同期処理のエラーは Promise.catchasync/await + try/catch の 2 系統があります。同期的に見える async/await の方が圧倒的に書きやすく、現代では基本的にこちらを推奨します。

6.1 Promise.catch の基本

.catch(handler).then(undefined, handler) のシンタックスシュガーです。チェーンの最後に置くのが原則で、catch 自身も新しい Promise を返します。

fetch("/api/user")
  .then((r) => r.json())
  .then((data) => console.log(data))
  .catch((e) => console.error("失敗:", e))
  .finally(() => console.log("完了"));

6.2 async/await + try/catch

async 関数内で投げられた例外は reject された Promise として伝播します。呼び出し側で try/catch で囲むことで、同期と全く同じ書き方ができます。

async function fetchUser(id) {
  try {
    const res = await fetch(`/api/users/${id}`);
    if (!res.ok) {
      throw new HttpError(`HTTP ${res.status}`, res.status, "FETCH_FAILED");
    }
    return await res.json();
  } catch (e) {
    if (e instanceof HttpError && e.statusCode === 404) {
      return null;
    }
    throw e;
  }
}

6.3 await を忘れた async 関数の罠

async 関数の戻り値を await せず呼ぶと、内部の例外は外側の try/catch では捕まりません。これは初学者が必ず踏むバグで、ESLint の no-floating-promises ルールで防げます。

async function task() {
  throw new Error("非同期エラー");
}

// ❌ await 無し → 外側で捕まらない
try {
  task(); // Promise を捨てている
} catch (e) {
  console.log("ここには来ない");
}

// ✅ await 付き → 正しく捕捉
try {
  await task();
} catch (e) {
  console.log("正しく捕捉:", e.message);
}

6.4 並列処理での部分失敗

Promise.all1 つでも reject すると即 reject します。全件結果が欲しいときは Promise.allSettled を使い、reject だけ集めて報告します。

const results = await Promise.allSettled([
  fetchUser(1),
  fetchUser(2),
  fetchUser(3),
]);

const successes = results.filter((r) => r.status === "fulfilled");
const failures  = results.filter((r) => r.status === "rejected");

console.log(`成功: ${successes.length}, 失敗: ${failures.length}`);
failures.forEach((f) => console.error(f.reason));

7. 横断的エラー捕捉(ブラウザ / Node.js)

個別の try/catch をすり抜けたエラーは、最終的にグローバルハンドラで捕捉します。ブラウザと Node.js でそれぞれ異なる API が用意されており、観測性のために必ず実装します。

7.1 ブラウザ: window.onerror

同期エラーの最後の砦は window.onerror または window.addEventListener("error", ...) です。Sentry など外部監視 SDK もここに hook します。

window.addEventListener("error", (event) => {
  console.error("[global error]", event.error);
  // Sentry.captureException(event.error);
  event.preventDefault(); // デフォルトのコンソール表示を抑止
});

// 関数形式(旧来)
window.onerror = function (message, source, line, col, error) {
  console.error("旧形式:", { message, source, line, col, error });
  return true; // true で伝播停止
};

7.2 ブラウザ: unhandledrejection

キャッチされなかった Promise reject は unhandledrejection イベントで通知されます。React や非同期 UI ではこちらが本命の捕捉口になります。

window.addEventListener("unhandledrejection", (event) => {
  console.error("[unhandled rejection]", event.reason);
  // Sentry.captureException(event.reason);
  event.preventDefault();
});

// テスト用: ハンドリングしない Promise
Promise.reject(new Error("どこにも catch されない"));

7.3 Node.js: uncaughtException / unhandledRejection

Node.js では process.on("uncaughtException")process.on("unhandledRejection") が対応します。原則として捕捉後はプロセスを終了すべきで、内部状態が壊れている可能性があるためです。

process.on("uncaughtException", (err, origin) => {
  console.error("[uncaughtException]", err, origin);
  // ログ出力後にプロセス終了
  process.exit(1);
});

process.on("unhandledRejection", (reason, promise) => {
  console.error("[unhandledRejection]", reason);
  // Node.js v15+ はデフォルトでプロセス終了
});

7.4 graceful shutdown

本番サーバーではグローバル捕捉後に即時 exit せず、新規リクエストを止めて既存リクエストを完了させてから終了します。

let isShuttingDown = false;

function gracefulExit(server, code = 1) {
  if (isShuttingDown) return;
  isShuttingDown = true;
  server.close(() => process.exit(code));
  // 強制タイムアウト
  setTimeout(() => process.exit(code), 10_000).unref();
}

process.on("uncaughtException", (err) => {
  console.error(err);
  gracefulExit(httpServer, 1);
});

8. TypeScript でのエラー処理 – catch (e: unknown)

TypeScript 4.0 以降、tsconfig.jsonuseUnknownInCatchVariables: true(strict でデフォルト有効)により、catch 節の例外は unknown 型として扱われます。これは「何が投げられるか分からない」JavaScript の現実を型で表現した、極めて正しい設計です。

8.1 unknown の正しい絞り込み

unknown 型のまま e.message にアクセスするとコンパイルエラーになります。instanceof Error で絞り込んでから使うのが基本です。

try {
  riskyOp();
} catch (e: unknown) {
  // ❌ e.message → コンパイルエラー
  // console.error(e.message);

  // ✅ 絞り込み
  if (e instanceof Error) {
    console.error(e.message);
  } else if (typeof e === "string") {
    console.error("文字列が投げられた:", e);
  } else {
    console.error("未知の値:", e);
  }
}

8.2 型ガード関数(isHttpError)

独自エラー判定は型述語 is を使って関数化します。複数箇所で再利用しつつ、型安全を確保できます。

function isHttpError(e: unknown): e is HttpError {
  return e instanceof Error && e.name === "HttpError";
}

function isValidationError(e: unknown): e is ValidationError {
  return e instanceof Error && e.name === "ValidationError";
}

try {
  await fetchUser(1);
} catch (e) {
  if (isValidationError(e)) {
    console.error("バリデーション:", e.fields);
  } else if (isHttpError(e)) {
    console.error("HTTP:", e.statusCode);
  } else if (e instanceof Error) {
    console.error("一般:", e.message);
  }
}

8.3 エラー正規化ヘルパー

何が投げられたか分からない値を必ず Error に正規化するヘルパーは、現場で最も重宝します。文字列・オブジェクト・null・undefined を全て Error に変換します。

function toError(value: unknown): Error {
  if (value instanceof Error) return value;
  if (typeof value === "string") return new Error(value);
  try {
    return new Error(JSON.stringify(value));
  } catch {
    return new Error(String(value));
  }
}

try {
  throw "文字列例外"; // ❌ アンチパターン
} catch (e) {
  const err = toError(e);
  console.error(err.message); // "文字列例外"
}

8.4 noImplicitAny との関係

TS 4.0 未満や useUnknownInCatchVariables: false では catch の引数は any になります。明示的に : unknown を書くことで、型安全を強制できます。

// 推奨: 明示的に unknown を指定
try {
  riskyOp();
} catch (e: unknown) {
  if (e instanceof Error) {
    console.error(e.message);
  }
}

9. Result 型 – エラーを値として扱う

例外は制御フローが見えにくくなる欠点があります。Rust や Haskell のようにエラーを戻り値の一部として表現する Result 型を導入すると、コンパイラがエラーハンドリングを強制できるようになり、業務ロジックの堅牢性が劇的に向上します。

9.1 Result 型の自前実装

最小実装は判別ユニオンで表現します。ok: true なら valueok: false なら error が存在することを型で保証します。

type Result<T, E = Error> =
  | { ok: true; value: T }
  | { ok: false; error: E };

const Ok  = <T,>(value: T): Result<T, never> => ({ ok: true, value });
const Err = <E,>(error: E): Result<never, E> => ({ ok: false, error });

function divide(a: number, b: number): Result<number, Error> {
  if (b === 0) return Err(new Error("Division by zero"));
  return Ok(a / b);
}

const r = divide(10, 0);
if (r.ok) {
  console.log(r.value);   // 型: number
} else {
  console.error(r.error); // 型: Error
}

9.2 Result 型での fetch ラッパー

fetch を Result 化することで、呼び出し側は例外を意識せず分岐できます。これは API クライアントの設計で特に効果が高いパターンです。

async function safeFetch<T>(
  url: string,
): Promise<Result<T, HttpError | Error>> {
  try {
    const res = await fetch(url);
    if (!res.ok) {
      return Err(new HttpError(`HTTP ${res.status}`, res.status, "FETCH_FAILED"));
    }
    const data = (await res.json()) as T;
    return Ok(data);
  } catch (e) {
    return Err(e instanceof Error ? e : new Error(String(e)));
  }
}

const result = await safeFetch<{ name: string }>("/api/me");
if (result.ok) {
  console.log(result.value.name);
} else {
  console.error(result.error);
}

9.3 neverthrow ライブラリ

neverthrow は Rust 風 Result を TypeScript で提供する事実上のデファクトです。map / mapErr / andThen でチェーン可能になり、宣言的に書けます。

// npm install neverthrow
import { Result, ok, err, ResultAsync } from "neverthrow";

function parseAge(input: string): Result<number, Error> {
  const n = Number(input);
  if (Number.isNaN(n)) return err(new Error("数値ではない"));
  if (n < 0)            return err(new Error("負の数"));
  return ok(n);
}

const r = parseAge("25")
  .map((age) => age + 1)
  .mapErr((e) => new Error(`変換失敗: ${e.message}`));

r.match(
  (age) => console.log("年齢:", age),
  (e)   => console.error(e.message),
);

9.4 neverthrow + ResultAsync

非同期版は ResultAsync です。fromPromise で Promise を Result に持ち上げ、andThen で連鎖します。

import { ResultAsync } from "neverthrow";

const fetchUser = (id: number) =>
  ResultAsync.fromPromise(
    fetch(`/api/users/${id}`).then((r) => r.json()),
    (e) => new Error(`fetch失敗: ${String(e)}`),
  );

const result = await fetchUser(1)
  .andThen((user) => fetchUser(user.friendId))
  .map((friend) => friend.name);

result.match(
  (name) => console.log(name),
  (e)    => console.error(e),
);

9.5 fp-ts Either

関数型寄りの選択肢として fp-tsEither があります。Left(失敗)/ Right(成功)の慣習で、pipe と組み合わせて使います。

// npm install fp-ts
import { Either, left, right, isLeft, isRight } from "fp-ts/Either";
import { pipe } from "fp-ts/function";
import * as E from "fp-ts/Either";

function parseInt2(s: string): Either<Error, number> {
  const n = parseInt(s, 10);
  return Number.isNaN(n) ? left(new Error("NaN")) : right(n);
}

const result = pipe(
  parseInt2("42"),
  E.map((n) => n * 2),
  E.mapLeft((e) => new Error(`変換失敗: ${e.message}`)),
);

if (isRight(result)) console.log(result.right); // 84

9.6 zod safeParse の Result

Zod の safeParse例外を投げず Result 風オブジェクトを返します。フォーム入力や API レスポンス検証はこちらを使うのが正解で、parse の throw に依存しない設計が可能です。

// npm install zod
import { z } from "zod";

const UserSchema = z.object({
  name: z.string().min(1),
  age:  z.number().int().nonnegative(),
});

const result = UserSchema.safeParse({ name: "", age: -1 });
if (result.success) {
  console.log(result.data);
} else {
  // result.error: ZodError
  result.error.issues.forEach((i) => console.error(i.path, i.message));
}

10. API / HTTP エラー設計

Web API のエラーはHTTP ステータスコードエラーボディの 2 軸で表現します。クライアント側ではこれをカスタム Error にマッピングし、UI 層には Result 型で返すのが現代的な設計です。

10.1 4xx と 5xx の責務分離

4xx はクライアント起因(入力不正・認証切れ・権限不足)、5xx はサーバー起因(DB 障害・タイムアウト)です。クライアントは 4xx を業務ロジックとして扱い、5xx を再試行候補として扱います。

function classifyHttp(status: number): "client" | "server" | "ok" {
  if (status >= 200 && status < 300) return "ok";
  if (status >= 400 && status < 500) return "client";
  if (status >= 500)                  return "server";
  throw new Error(`想定外のステータス: ${status}`);
}

async function callApi(url: string) {
  const res = await fetch(url);
  const kind = classifyHttp(res.status);
  if (kind === "client") throw new HttpError("クライアントエラー", res.status, "CLIENT");
  if (kind === "server") throw new HttpError("サーバーエラー", res.status, "SERVER");
  return res.json();
}

10.2 リトライ戦略(指数バックオフ)

5xx やネットワーク失敗指数バックオフ + ジッターで再試行します。4xx は再試行不可(429 を除く)で、即座にユーザーへ通知します。

async function retryable<T>(
  fn: () => Promise<T>,
  { retries = 3, baseMs = 200 }: { retries?: number; baseMs?: number } = {},
): Promise<T> {
  let lastError: unknown;
  for (let i = 0; i <= retries; i++) {
    try {
      return await fn();
    } catch (e) {
      lastError = e;
      // 4xx(429 除く)は即座に諦める
      if (e instanceof HttpError && e.statusCode < 500 && e.statusCode !== 429) {
        throw e;
      }
      if (i === retries) break;
      const wait = baseMs * 2 ** i + Math.random() * 100;
      await new Promise((r) => setTimeout(r, wait));
    }
  }
  throw lastError;
}

const data = await retryable(() => callApi("/api/users"));

10.3 AbortController によるキャンセル

長時間処理は AbortController でキャンセル可能にします。AbortErrorエラーではなく意図したキャンセルとして、ログレベルを下げて扱います。

async function fetchWithTimeout(url: string, ms: number) {
  const ac = new AbortController();
  const timer = setTimeout(() => ac.abort(), ms);
  try {
    return await fetch(url, { signal: ac.signal });
  } catch (e) {
    if (e instanceof DOMException && e.name === "AbortError") {
      throw new Error(`タイムアウト ${ms}ms`);
    }
    throw e;
  } finally {
    clearTimeout(timer);
  }
}

10.4 RFC 7807 problem+json

サーバー側のエラーレスポンスは RFC 7807 (problem+json) に準拠すると、クライアントの実装が単純化されます。クライアントも同形式で受ける前提でラッパーを書きます。

type Problem = {
  type: string;
  title: string;
  status: number;
  detail?: string;
  instance?: string;
};

async function callApiRfc(url: string) {
  const res = await fetch(url);
  if (!res.ok) {
    const problem = (await res.json()) as Problem;
    throw new HttpError(problem.title, problem.status, problem.type);
  }
  return res.json();
}

11. React のエラー処理

React のレンダリング中に投げられた例外は Error Boundary で捕捉します。React 19 ではさらに Suspense 連携の throwOnErroronCaughtError / onUncaughtError ルートオプションが追加されました。

11.1 クラスコンポーネントの Error Boundary

Error Boundary はクラスコンポーネントでしか作れません(2026 年時点)。getDerivedStateFromError で UI を切り替え、componentDidCatch でログ送信します。

import { Component, type ReactNode, type ErrorInfo } from "react";

type Props = { children: ReactNode; fallback: ReactNode };
type State = { hasError: boolean; error: Error | null };

export class ErrorBoundary extends Component<Props, State> {
  state: State = { hasError: false, error: null };

  static getDerivedStateFromError(error: Error): State {
    return { hasError: true, error };
  }

  componentDidCatch(error: Error, info: ErrorInfo) {
    console.error("[ErrorBoundary]", error, info.componentStack);
    // Sentry.captureException(error, { extra: info });
  }

  render() {
    if (this.state.hasError) return this.props.fallback;
    return this.props.children;
  }
}

11.2 react-error-boundary パッケージ

react-error-boundary はリセット機能や hook 連携を備えた事実上のデファクトです。多くの実案件はこちらを採用しています。

// npm install react-error-boundary
import { ErrorBoundary } from "react-error-boundary";

function Fallback({ error, resetErrorBoundary }) {
  return (
    <div role="alert">
      <p>エラー: {error.message}</p>
      <button onClick={resetErrorBoundary}>再試行</button>
    </div>
  );
}

<ErrorBoundary
  FallbackComponent={Fallback}
  onError={(error, info) => console.error(error, info)}
  onReset={() => window.location.reload()}
>
  <MyApp />
</ErrorBoundary>

11.3 React 19 の onCaughtError / onUncaughtError

React 19 では createRoot のオプションでルート全体のエラーを捕捉できます。Error Boundary で捕捉済みかどうかで分岐できるのが特徴です。

import { createRoot } from "react-dom/client";

const root = createRoot(document.getElementById("root")!, {
  onCaughtError: (error, info) => {
    // Error Boundary で捕捉済みのエラー
    console.warn("[caught]", error, info.componentStack);
  },
  onUncaughtError: (error, info) => {
    // どの Boundary でも捕捉されなかったエラー
    console.error("[uncaught]", error, info.componentStack);
  },
  onRecoverableError: (error, info) => {
    // ハイドレーション不一致など、自動回復したエラー
    console.warn("[recoverable]", error, info);
  },
});

11.4 TanStack Query の throwOnError

TanStack Query はデフォルトで例外を投げずerror プロパティを返します。Error Boundary に伝播させたい場合は throwOnError: true を設定します。

import { useQuery } from "@tanstack/react-query";

const { data, error } = useQuery({
  queryKey: ["user", id],
  queryFn:  () => fetchUser(id),
  throwOnError: (error, query) => {
    // 5xx だけ Error Boundary に飛ばす
    return error instanceof HttpError && error.statusCode >= 500;
  },
  retry: (failureCount, error) => {
    if (error instanceof HttpError && error.statusCode < 500) return false;
    return failureCount < 3;
  },
});

11.5 イベントハンドラ内のエラー

onClick などのイベントハンドラ内で投げられた例外は Error Boundary に届きません(レンダリング中ではないため)。手動で useErrorBoundary hook 経由で投げ直すか、自前 state でフォールバックを切り替えます。

import { useErrorBoundary } from "react-error-boundary";

function MyButton() {
  const { showBoundary } = useErrorBoundary();

  const onClick = async () => {
    try {
      await dangerousOp();
    } catch (e) {
      showBoundary(e); // Error Boundary に投げ直す
    }
  };

  return <button onClick={onClick}>実行</button>;
}

12. Express / Fastify でのエラー処理

サーバーフレームワークはエラーハンドリングミドルウェアを 1 箇所に集約することで、各エンドポイントから try/catch を消せます。

12.1 Express エラーハンドラの定義

Express のエラーハンドラは引数 4 つ(err, req, res, next)の関数で、app.use最後に登録します。

import express from "express";
const app = express();

app.get("/users/:id", async (req, res, next) => {
  try {
    const user = await fetchUser(Number(req.params.id));
    if (!user) throw new NotFoundError("user");
    res.json(user);
  } catch (e) {
    next(e); // エラーハンドラへ
  }
});

// エラーハンドラは最後
app.use((err, req, res, next) => {
  console.error(err);
  if (err instanceof HttpError) {
    return res.status(err.statusCode).json({
      type:   err.code,
      title:  err.message,
      status: err.statusCode,
    });
  }
  res.status(500).json({ type: "INTERNAL", title: "サーバーエラー", status: 500 });
});

12.2 async wrapper でtry/catch を省く

Express 4 は async 関数の reject を自動転送しないため、ラッパーを通すと毎回の try/catch が省けます(Express 5 では不要)。

const asyncHandler = (fn) => (req, res, next) =>
  Promise.resolve(fn(req, res, next)).catch(next);

app.get("/users/:id", asyncHandler(async (req, res) => {
  const user = await fetchUser(Number(req.params.id));
  if (!user) throw new NotFoundError("user");
  res.json(user);
}));

12.3 Fastify の setErrorHandler

Fastify は async ネイティブで、setErrorHandler を 1 度登録すれば全エンドポイントを集約できます。

import Fastify from "fastify";
const app = Fastify();

app.setErrorHandler((error, req, reply) => {
  req.log.error(error);
  if (error instanceof HttpError) {
    return reply.code(error.statusCode).send({
      type:   error.code,
      title:  error.message,
      status: error.statusCode,
    });
  }
  reply.code(500).send({ type: "INTERNAL", title: "サーバーエラー", status: 500 });
});

app.get("/users/:id", async (req) => {
  const user = await fetchUser(Number(req.params.id));
  if (!user) throw new NotFoundError("user");
  return user;
});

13. 観測性 – Sentry / OpenTelemetry / 構造化ログ

本番でエラーを検知・分析・修正するためには、エラー処理は観測性スタックと統合されている必要があります。最低限 Sentry、可能なら OpenTelemetry の trace に紐付けます。

13.1 Sentry 連携

Sentry はエラー報告のデファクトです。captureException でエラーを送信し、setUser / setTag / setContext でメタデータを付与します。

// npm install @sentry/node @sentry/react
import * as Sentry from "@sentry/node";

Sentry.init({
  dsn: process.env.SENTRY_DSN,
  environment: process.env.NODE_ENV,
  tracesSampleRate: 0.1,
});

try {
  await dangerousOp();
} catch (e) {
  Sentry.captureException(e, {
    tags:  { feature: "checkout" },
    extra: { orderId: "abc-123" },
    level: "error",
  });
  throw e;
}

13.2 React の Sentry Error Boundary

Sentry は React 用の Error Boundary を提供します。Sentry.ErrorBoundary でラップするだけで、ロケーションやコンポーネントスタックが自動収集されます。

import * as Sentry from "@sentry/react";

<Sentry.ErrorBoundary
  fallback={({ error, resetError }) => (
    <div>
      <p>エラー: {error.message}</p>
      <button onClick={resetError}>リセット</button>
    </div>
  )}
  showDialog
>
  <App />
</Sentry.ErrorBoundary>

13.3 OpenTelemetry の trace と紐付け

OpenTelemetry の active span に recordException でエラーを記録すると、trace ID と紐付いたエラーになります。これで分散システム横断の追跡が可能になります。

// npm install @opentelemetry/api
import { trace, SpanStatusCode } from "@opentelemetry/api";

const tracer = trace.getTracer("checkout-service");

async function checkout(orderId) {
  return tracer.startActiveSpan("checkout", async (span) => {
    try {
      span.setAttribute("order.id", orderId);
      return await processOrder(orderId);
    } catch (e) {
      span.recordException(e);
      span.setStatus({ code: SpanStatusCode.ERROR, message: e.message });
      throw e;
    } finally {
      span.end();
    }
  });
}

13.4 構造化ログ(pino / winston)

ログは必ず JSON 構造化で出力します。pino は高速で、エラーオブジェクトをシリアライズする際 cause もそのまま展開します。

// npm install pino
import pino from "pino";
const logger = pino({ level: "info" });

try {
  await dangerousOp();
} catch (e) {
  logger.error({ err: e, orderId: "abc-123" }, "checkout 失敗");
  // 出力: { "level":50, "err":{ "type":"Error", "message":"...", "stack":"...", "cause":{...} }, ... }
}

13.5 ログレベル設計

ログレベルは事業影響で決めます。fatal は即対応・error は朝確認・warn は集計確認・info は通常ログ・debug は調査時のみ、と分けると運用が安定します。

function logByKind(error: unknown) {
  if (error instanceof ValidationError) {
    logger.info({ err: error }, "ユーザー入力エラー"); // 業務イベント
  } else if (error instanceof HttpError && error.statusCode < 500) {
    logger.warn({ err: error }, "4xxエラー");
  } else if (error instanceof HttpError) {
    logger.error({ err: error }, "5xxエラー");
  } else {
    logger.fatal({ err: error }, "想定外エラー"); // ページャー発動
  }
}

14. アンチパターン総まとめと Lint 設定

最後に、これまで散らばっていたエラー処理アンチパターンを 1 箇所にまとめ、ESLint / TypeScript で機械的に防ぐ設定を示します。

14.1 やってはいけない 10 のパターン

以下は本番コードで見つけたら即修正すべきパターンです。それぞれ理由と修正方針を示します。

// ❌ 1. 文字列を throw
throw "エラー"; // スタックトレース消失

// ❌ 2. 空 catch で握り潰し
try { ... } catch (e) {}

// ❌ 3. console.log してそのまま続行
try { ... } catch (e) { console.log(e); /* 何もせず継続 */ }

// ❌ 4. 全部キャッチで型情報破壊
try { ... } catch (e: any) { /* unknown にすべき */ }

// ❌ 5. await 忘れ
async function f() { task(); /* await 漏れ */ }

// ❌ 6. Promise.all で全停止
await Promise.all([...]); // 部分結果が欲しいなら allSettled

// ❌ 7. finally で return
function f() { try { return "A"; } finally { return "B"; } }

// ❌ 8. cause を使わずメッセージ連結
throw new Error(`失敗: ${originalError.message}`); // スタック消失

// ❌ 9. instanceof Error なしで .message 参照
catch (e) { return e.message; /* unknown のまま */ }

// ❌ 10. setTimeout 内の throw を try/catch で囲む
try { setTimeout(() => { throw new Error(); }, 0); } catch (e) { /* 捕まらない */ }

14.2 ESLint ルール設定

主要なルールを .eslintrc または eslint.config.js で有効化します。@typescript-eslint と組み合わせると効果が最大化します。

// eslint.config.js
export default [
  {
    rules: {
      // 文字列 throw を禁止
      "no-throw-literal": "error",
      "@typescript-eslint/only-throw-error": "error",

      // catch の引数を unknown 強制
      "@typescript-eslint/use-unknown-in-catch-callback-variable": "error",

      // await 忘れを検知
      "@typescript-eslint/no-floating-promises": "error",
      "@typescript-eslint/no-misused-promises": "error",

      // return await の落とし穴
      "@typescript-eslint/return-await": ["error", "always"],

      // 空 catch ブロック
      "no-empty": ["error", { allowEmptyCatch: false }],

      // Promise.catch 連鎖の型保全
      "@typescript-eslint/prefer-promise-reject-errors": "error",
    },
  },
];

14.3 tsconfig.json の安全設定

TypeScript 側でも以下を有効化すると、catch の型安全と暗黙の any を排除できます。

// tsconfig.json(抜粋)
{
  "compilerOptions": {
    "strict": true,
    "useUnknownInCatchVariables": true,  // strict で自動有効
    "exactOptionalPropertyTypes": true,
    "noImplicitOverride": true,
    "noUncheckedIndexedAccess": true
  }
}

14.4 エラー処理を強制する設計指針

最後に、設計レベルでエラー処理を「忘れさせない」ための 5 指針をまとめます。

// 設計指針(コードで自己説明)

// 1. 危険なAPIは Result を返し、生 throw しない
function safeDivide(a: number, b: number): Result<number, Error> {
  return b === 0 ? Err(new Error("zero")) : Ok(a / b);
}

// 2. ドメイン例外はカスタム Error + cause で連鎖
class CheckoutError extends Error {
  constructor(message: string, cause?: unknown) {
    super(message, { cause });
    this.name = "CheckoutError";
  }
}

// 3. 横断ハンドラ(window.onerror, uncaughtException)は必須
process.on("unhandledRejection", (r) => logger.fatal({ err: r }));

// 4. Sentry + OpenTelemetry を初期化時にセット
Sentry.init({ dsn: process.env.SENTRY_DSN });

// 5. テストでエラーパスも必ず assert
test("zero division returns Err", () => {
  const r = safeDivide(1, 0);
  expect(r.ok).toBe(false);
});

15. まとめ – 現代 JS / TS のエラー処理 5 原則

本記事で扱った内容を、現場で守るべき5 原則に凝縮して終わります。

原則 1: 必ず Error インスタンスを投げる。文字列・オブジェクトリテラルは禁止。Lint で no-throw-literal / only-throw-error を強制。

原則 2: catch の引数は unknown として絞り込むinstanceof や型ガード関数で narrowing し、生で .message を触らない。

原則 3: ラップする時は { cause } を必ず付ける。低レイヤのスタックトレースを失わず、運用時の調査コストを下げる。

原則 4: 業務ロジックは Result 型で表現する。例外より戻り値の方が型システムが強制力を持ち、テストも書きやすい。neverthrow / fp-ts / 自前実装のどれでも良い。

原則 5: 観測性は最初から組み込む。Sentry + 構造化ログ + OpenTelemetry を初期化時にセットし、グローバルハンドラ(unhandledrejection / uncaughtException)で最後の砦を作る。

エラー処理は「動いてしまえば気にならない」部分ですが、本番障害の解像度はここの設計品質で決まります。本記事を通じて、Promise / async/await / 型ガードの知識と組み合わせ、堅牢なシステムを構築する一助となれば幸いです。

コメント

タイトルとURLをコピーしました