ウェブエンジニア問題集

エラーハンドリング — try/catch/finally・カスタムエラー・非同期のエラー

プログラムは思いどおりに動かないことがあります。APIが落ちている、ユーザーが想定外の値を入力した、ファイルが存在しない——こうした「失敗」をどう受け止め、どう立て直すかを決めるのがエラーハンドリングです。

前章までで throw で例外を「投げる」側は見ました。この章は、投げられた例外を受け止めて処理を続ける側、つまり try...catch...finally と、自分でエラーの種類を設計するカスタムエラークラス、そして非同期処理(async/await・Promise)でのエラーの捕まえ方を整理します。

学習者学習者

try...catch は書けるけど、catch (e)e に何が入ってるのか、finally をいつ使うのか、よく分かってない…。await のエラーが捕まえられなくてハマったこともある。

その「なんとなく」を一つずつ潰していきましょう。throw 自体の基礎(throw は文、文字列を投げない、など)は制御構文の章で扱っているので、ここでは捕まえる側に集中します。

try...catch の基本 — エラーが起きても処理を止めない

JavaScriptは、エラー(例外)が発生するとその場で処理を中断し、呼び出し元へさかのぼっていきます。最後まで誰も受け止めなければ、プログラム全体が止まります。try...catch は、この「止まる」を自分のコードで受け止めるための仕組みです。

try {
  const data = JSON.parse('壊れたJSON'); // ここで例外が発生
  console.log(data); // 実行されない
} catch (error) {
  console.log('パースに失敗しました'); // ここが実行される
}
 
console.log('処理は続行する'); // catch で受け止めたので、ここも実行される

try ブロックの中でエラーが起きると、JavaScriptは残りの try を飛ばして即座に catch ブロックへジャンプします。受け止めたあとは、try...catch外側の処理はそのまま続きます

先生先生

ポイントは「try の中でエラーが起きた瞬間に、残りの行は飛ばして catch に移る」こと。try を最後まで実行してから catch に行くわけじゃないんだ。

実行の流れを図にすると次のようになります。finally(後述)まで含めると、分岐があってもゴールは1か所に集まります。

catch が受け取る error オブジェクト — name・message・stack

catch (error)error には、throw された値が入ります。慣習に従って Error オブジェクトが投げられていれば、デバッグに使える情報がプロパティとして詰まっています。

プロパティ説明
nameエラーの種類名。"Error""TypeError" など。エラーの分類に使う
messageコンストラクタに渡した説明文。new Error('xxx')'xxx' がここに入る
stackエラー発生箇所までの呼び出し履歴(スタックトレース)。デバッグ用。標準化はされていないがほぼ全環境で使える
cause原因となった元のエラー(ES2022で追加。後述)
try {
  null.toUpperCase(); // null にメソッドはない → TypeError
} catch (error) {
  console.log(error.name); // "TypeError"
  console.log(error.message); // "Cannot read properties of null (reading 'toUpperCase')"
  console.log(error.stack); // 呼び出し履歴(複数行の文字列)
}

catch の引数は省略できる

エラーの中身を使わず「失敗したかどうか」だけ分かればよい場合、catch の引数((error) の部分)は省略できます(ES2019、Optional catch binding)。

function isValidJson(text) {
  try {
    JSON.parse(text);
    return true;
  } catch {
    // error を使わないので括弧ごと省略できる
    return false;
  }
}
学習者学習者

catch (e) {} って書いてたけど、使わないなら catch {} でいいんだ。

組み込みのエラー型 — TypeError・RangeError…

JavaScriptには、状況に応じた組み込みのエラー型があります。すべて Error を継承していて、error.name で種類を見分けられます。自分でエラーを投げるときも、内容に合った型を選ぶと、受け取る側が分類しやすくなります。

どんなときに発生するか
TypeError値が期待した型でない。null のプロパティ参照、関数でないものの呼び出しなど(最頻出)
RangeError値が許容範囲外。配列長に負数を入れる、再帰が深すぎるなど
ReferenceError存在しない変数を参照した
SyntaxError構文エラー。JSON.parse の失敗もこれ
URIErrordecodeURIComponent などに不正な文字列を渡した
AggregateError複数のエラーをまとめたもの。Promise.any が全部失敗したときなど
// 値の型がおかしい → TypeError
function double(n) {
  if (typeof n !== 'number') {
    throw new TypeError(`数値が必要です: ${typeof n}`);
  }
  return n * 2;
}
 
// 範囲がおかしい → RangeError
function setVolume(v) {
  if (v < 0 || v > 100) {
    throw new RangeError(`音量は0〜100です: ${v}`);
  }
}

finally — 成否にかかわらず必ず実行する

finally ブロックは、try が成功しても catch でエラーを受け止めても、どちらの場合も必ず最後に実行されます。「成否に関係なく後始末したい処理」を書く場所です。

代表例はリソースの解放です。ファイルを閉じる、ローディング表示を消す、接続を切る——成功しても失敗しても確実にやりたい処理を finally に置きます。

function loadData() {
  showSpinner(); // ローディング開始
  try {
    const data = fetchSync();
    render(data);
  } catch (error) {
    showError(error.message);
  } finally {
    hideSpinner(); // 成功しても失敗しても、必ずスピナーを消す
  }
}
先生先生

trycatch の両方に hideSpinner() を書くと、消し忘れや重複の温床になる。「どっちのルートでも通る後始末」は finally に一本化するのが定石だよ。

throw と再スロー — 握りつぶさず、必要なら投げ直す

catch で受け止めたからといって、何ごともなかったことにして良いとは限りません。自分で対処できないエラーは、より上の呼び出し元に判断を委ねるべきです。そのために catch の中でもう一度 throw するのが「再スロー」です。

function loadConfig(path) {
  try {
    return readFile(path);
  } catch (error) {
    // ここでログだけ残して、判断は呼び出し元に委ねる
    console.error(`設定ファイルの読み込みに失敗: ${path}`, error);
    throw error; // 再スロー:握りつぶさず上へ伝える
  }
}
投げ直したエラーが上の呼び出し元に戻っていくイメージ

最もやってはいけないのが、catch で受け止めておきながら何もしない「握りつぶし」です。エラーが起きても画面上は正常に見えるため、原因不明のバグとして後から苦しむことになります。

// ❌ アンチパターン:エラーを握りつぶす
try {
  saveToServer(data);
} catch {
  // 何もしない → 保存に失敗しても誰も気づけない
}
学習者学習者

とりあえず try...catch で囲んでおけば落ちないから安心、って思ってた…。

「落ちない」のと「正しく動く」のは別物です。catch に入ったら、最低でもログを残す、対処できないなら再スローする、ユーザーにエラーを伝える——このいずれかは必ず行いましょう。

カスタムエラークラス — Error を継承して種類を作る

アプリが大きくなると、「これは入力ミス」「これは権限不足」「これはネットワーク障害」のように、エラーを種類で区別して扱い分けたくなります。組み込みの Errorextends して、独自のエラー型を定義できます。

class ValidationError extends Error {
  constructor(message, field) {
    super(message); // 親 Error の初期化(message をセット)
    this.name = 'ValidationError'; // name は自分で設定する
    this.field = field; // 独自の情報を持たせられる
  }
}
 
throw new ValidationError('メールアドレスの形式が不正です', 'email');

ポイントは2つです。super(message) で親の Error を初期化して message をセットすること、そして this.name自分で設定すること(設定しないと name"Error" のままになります)。field のように、エラー固有の情報を好きなだけ持たせられるのがカスタムエラーの強みです。

受け取る側は instanceof で種類を判定し、種類ごとに対応を変えられます。

try {
  validateForm(input);
} catch (error) {
  if (error instanceof ValidationError) {
    // 入力ミス → その項目にエラー表示
    highlightField(error.field, error.message);
  } else {
    // それ以外 → 想定外なので上へ投げ直す
    throw error;
  }
}
先生先生

error.message の文字列で if (message.includes('...')) と判定するのは脆い。型(クラス)で分岐できるようにしておくと、メッセージを変えても壊れないんだ。

cause で「元のエラー」を保持する

低レベルのエラー(例:ネットワークエラー)を、より分かりやすいエラーに包み直して投げることがあります。このとき元のエラーを捨ててしまうと原因が追えなくなるので、Error の第2引数 options.cause(ES2022)に元のエラーを渡して保持します。

構文: new Error(message, options?)

引数説明
messageエラーの説明文(error.message に入る)
options(省略可){ cause: 元のエラー } を渡すと、error.cause から原因をたどれる
async function loadUser(id) {
  try {
    return await fetchUser(id);
  } catch (error) {
    // 元のエラーを cause に残しつつ、文脈を足して投げ直す
    throw new Error(`ユーザー(${id})の取得に失敗しました`, { cause: error });
  }
}

非同期処理のエラーハンドリング

ここからが多くの人がつまずく本題です。非同期処理では、エラーが少し遅れてやってきます。捕まえ方は「async/await か Promise か」で書き方が変わります。

async/await — 同期と同じく try...catch で囲む

await した処理が失敗(reject)すると、その行で例外が投げられます。つまり同期コードと同じ感覚で try...catch で囲めます。これが async/await の最大のメリットです。

async function showUser(id) {
  try {
    const res = await fetch(`/api/users/${id}`);
    if (!res.ok) {
      // fetch は404でも reject しない → 自分で throw する
      throw new Error(`HTTP ${res.status}`);
    }
    const user = await res.json();
    render(user);
  } catch (error) {
    showError(`読み込みに失敗しました: ${error.message}`);
  }
}

Promise — .catch() でつなぐ

.then() チェーンで書く場合は、末尾に .catch() を付けてエラーを受け止めます。チェーンのどこで失敗しても、まとめて .catch() に流れてきます。

fetch('/api/users/1')
  .then((res) => res.json())
  .then((user) => render(user))
  .catch((error) => showError(error.message)); // どこで失敗してもここに来る
学習者学習者

async/await.then().catch()、どっちで書けばいいの?

新しく書くなら、同期コードと同じ見た目で読める async/await + try...catch が基本です。.catch() は、短い単発の処理や、既存の Promise チェーンに合わせるときに使います。

複数の非同期処理 — Promise.all / allSettled / any

複数の Promise をまとめて扱うとき、1つ失敗したらどうするかで使うメソッドが変わります。

メソッド1つ失敗したときの挙動戻り値
Promise.all(iterable)即座に reject。1つでもコケると全体が失敗扱い全成功なら結果の配列
Promise.allSettled(iterable)reject しない。全部の結果を待つ各要素が { status, value/reason } の配列
Promise.any(iterable)1つでも成功すれば成功。全部失敗で AggregateError最初に成功した値
// all:1つでも失敗したら catch に飛ぶ(成功した分の結果も受け取れない)
try {
  const [a, b] = await Promise.all([fetchA(), fetchB()]);
} catch (error) {
  console.error('どれかが失敗しました', error);
}
 
// allSettled:失敗しても止めず、成功分だけ拾いたいとき
const results = await Promise.allSettled([fetchA(), fetchB()]);
const ok = results.filter((r) => r.status === 'fulfilled').map((r) => r.value);
先生先生

「全部そろわないと意味がない」なら all、「コケたものは飛ばして、取れたものだけ使いたい」なら allSettled。この使い分けは実務で本当によく出てくるよ。

よくあるハマりどころ

非同期エラーのハマりどころを確認するイメージ

1. await を付け忘れると try...catch で捕まえられない

これが非同期エラー最大の落とし穴です。await を付け忘れた Promise の失敗は、try ブロックを素通りして外に漏れます。try を抜けた後で reject するため、catch には入りません。

// ❌ await がない → reject が catch に捕まらない
async function bad() {
  try {
    saveToServer(data); // Promise を投げっぱなし(await なし)
  } catch (error) {
    // ここには来ない!未処理のまま外に漏れる
  }
}
 
// ⭕ await を付ければ、失敗はその行で例外になり catch に入る
async function good() {
  try {
    await saveToServer(data);
  } catch (error) {
    showError(error.message);
  }
}
学習者学習者

catch が動かないからエラーハンドリングが壊れてると思ってたけど、原因は await の付け忘れだったのか…!

2. 同期の try...catch では非同期エラーを捕まえられない

setTimeout のコールバックなど、後から別の文脈で実行される処理の中のエラーは、それを仕掛けた側の try...catch では捕まりません。コールバックが動くころには、もう try ブロックを抜けているからです。

// ❌ setTimeout の中のエラーは、この try では捕まえられない
try {
  setTimeout(() => {
    throw new Error('遅れて発生'); // どこにも捕まらず未処理に
  }, 1000);
} catch (error) {
  // ここには来ない
}

捕まえたいなら、try...catchコールバックの中に書きます。

3. catch した値が必ずしも Error とは限らない

throw は技術的にどんな値でも投げられるため、ライブラリやコードによっては文字列やオブジェクトが投げられることがあります。error.message を無条件にアクセスすると、それ自体でエラーになることも。instanceof Error で確認してから扱うと安全です。

try {
  doSomething();
} catch (error) {
  const message = error instanceof Error ? error.message : String(error);
  showError(message);
}

ちゃんと使うためのポイント

  • try...catchエラーで処理を止めずに受け止めるための仕組み。try 内でエラーが起きた瞬間に catch へジャンプする
  • catcherror には namemessagestack が入る。ログには error 全体を渡してスタックトレースを残す
  • finally成否にかかわらず必ず実行される。後始末(リソース解放)を一本化する場所。return/throw は書かない
  • 握りつぶし禁止。 catch に入ったら最低でもログを残し、対処できないなら再スローする
  • エラーを種類で扱い分けたいなら Error を継承したカスタムエラークラスを作り、instanceof で分岐する。元の原因は cause で残す
  • async/await のエラーは try...catch、Promise チェーンは .catch()await の付け忘れcatch をすり抜けるので最注意

次の章では、コードをファイル単位で分割して再利用する モジュール(import / export) を扱います。エラーを投げるユーティリティやカスタムエラークラスを別ファイルに切り出して使い回す、という形でも自然につながっていきます。

エラーハンドリングを習得してステップアップするイメージ