try {
await fetchProfile();
} catch (error) {
// error の型は unknown
}
TypeScriptでは、catch した値が unknown になります。関数の型にも「何を投げるか」は現れません。だから、失敗を投げるか値として返すかは呼び出し側から見えず、書くたびに迷います。
どちらか一方に統一するのではなく、失敗の性質に合った伝え方を選ぶために、JavaScript と React のエラーハンドリングを整理します。
JavaScript の例外
JavaScriptでは、throwで例外を発生させます。慣習として投げるのはErrorオブジェクトですが、言語上は文字列や数値も投げられます。そのためTypeScriptのcatch変数は、unknownとして扱われます。
function fail() {
throw new Error("Something went wrong");
}
try {
fail();
} catch (error) {
if (error instanceof Error) {
console.error(error.message);
}
}
例外が投げられると、実行はコールスタック上で最初に見つかった catch へ移ります。捕捉されなければ、実行環境やフレームワークが未処理の例外として扱います。
元の原因を cause で残す
低レベルのエラーを、文脈の分かるエラーに包み直したいことがあります。そのときは Error の cause に元の例外を渡すと、原因を失わずに上位へ伝えられます。
async function loadProfile() {
try {
return await fetchProfile();
} catch (error) {
throw new Error("プロフィールを取得できませんでした", {
cause: error,
});
}
}
cause の表示方法はブラウザやログ基盤によって異なるため、表示形式に依存せず、必要なら監視サービスへ構造化して送る方が安全です。
React コンポーネントのエラー
レンダー中に投げられたエラーは Error Boundary で捕捉し、その部分の UI をフォールバックへ切り替えられます。ただし Error Boundary が扱うのは、配下のレンダーやライフサイクルで発生したエラーです。イベントハンドラーや任意の非同期コールバックで後から投げられた例外まで、自動で捕捉するわけではありません。
React 本体で Error Boundary を定義するには、現在もクラスコンポーネントが必要です。自作を避けたい場合は react-error-boundary のようなライブラリも選択肢になります。
import { Suspense } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { AlbumDetail } from "./AlbumDetail";
import { FailedToLoad } from "./FailedToLoad";
import { Loading } from "./Loading";
export function Page() {
return (
<ErrorBoundary fallback={<FailedToLoad />}>
<Suspense fallback={<Loading />}>
<AlbumDetail />
</Suspense>
</ErrorBoundary>
);
}
Suspense は「読み込み中」の表示、Error Boundary は「失敗した」表示を担当します。この組み合わせが機能するのは、フレームワークや use 対応のデータソースなど、Suspense と統合された仕組みが Promise とエラーを React に伝える場合です。useEffect の中で普通に fetch するだけでは、Suspense が読み込み状態を管理してくれるわけではありません。
Next.js App Router の場合
Next.js App Router では、ルートセグメントに error.tsx を置くと、そのセグメントを Error Boundary で包めます。reset を呼ぶと、失敗したセグメントの再レンダーを試みられます。
"use client";
import { useEffect } from "react";
export default function ErrorPage({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
reportError(error);
}, [error]);
return (
<div>
<p>読み込みに失敗しました。</p>
<button type="button" onClick={reset}>
もう一度試す
</button>
</div>
);
}
一方、フォームの入力エラーや API の「見つからない」といった予想できる失敗は、例外にせず戻り値として扱うことが Next.js でも推奨されています。Error Boundary は、通常の分岐で表示できる状態の代わりではありません。
関数の失敗をどう表すか
関数では、呼び出し元にその場で判断してほしい失敗なのか、通常の処理を続けられない予期しない失敗なのかを基準に考えます。
捕捉した値を安全に扱う
catch した値を使う前に、まず型を絞り込みます。最低限のメッセージだけが必要なら、小さな変換関数を用意してもよいでしょう。
function getErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
if (typeof error === "string") return error;
return "Unknown error";
}
Axios の isAxiosError のように、ライブラリが型ガードを提供している場合はそれを利用できます。この絞り込みについては、Kent C. Dodds の記事が詳しいです。
ただし、捕捉しただけで問題を解決したことにはなりません。回復する、ユーザーへ伝える、文脈を足して再送出する、監視へ報告するなど、その層が責任を持てる処理を選びます。
予想できる失敗を値で返す
バリデーションエラーや在庫切れのように、業務上起こり得る失敗はタグ付きユニオンで表すと、呼び出し元に必要な分岐を型で伝えられます。
type Result<T, E> =
| { ok: true; value: T }
| { ok: false; error: E };
type SaveProfileError =
| { type: "invalid-name"; message: string }
| { type: "conflict"; message: string };
function saveProfile(): Result<Profile, SaveProfileError> {
// 処理に応じた結果を返す
}
冒頭の unknown と違い、どのように失敗するかが型に現れます。呼び出し元は error.type ごとに UI や回復方法を決められます。
例外として投げる
接続の切断、壊れたレスポンス、成立しない内部状態など、その場で正常系を続けられない失敗は例外が向いています。複数の層をまたいで同じ場所へ集約したい場合にも、スタックを巻き戻す例外の性質が役立ちます。
想定内の失敗まで何でも例外にすると、通常の制御フローが見えにくくなります。逆に、予期しない障害まで戻り値にすると、無視されたまま処理が続く危険があります。
さいごに
私の判断基準はシンプルです。
- ユーザーや呼び出し元が回復できる、予想済みの失敗は値で返す
- 正常な処理を続けられない、予期しない失敗は例外として投げる
- UI のレンダー失敗は Error Boundary で隔離し、復旧手段を用意する
一つの方法へ寄せる必要はありません。失敗を握りつぶさず、その意味と責任が伝わる形で次の層へ渡します。