JavaScript | 非同期処理:設計・理解の深化 - 例外仕様の文書化

JavaScript JavaScript
スポンサーリンク

なぜ「例外仕様の文書化」がそんなに大事なのか

非同期処理は「うまくいくとき」だけ見ていると、わりと簡単そうに見えます。
await fetch(...) して、res.json() して、画面に出す——これで終わり。

でも、実際の現場では「うまくいかないとき」が必ずあります。
ネットワークエラー、タイムアウト、サーバーエラー、パース失敗、権限エラー…。

ここで重要になるのが
「この非同期関数は、失敗したときにどう振る舞うのか?」を、コードと文章で“仕様として”はっきりさせること
です。

それが「例外仕様の文書化」です。

動けばいい、ではなく
「失敗の仕方まで含めて設計し、それを他人が分かる形で残す」
ここから一気に“プロの設計”に近づきます。


まず「この関数はどう失敗しうるか」を言語化する

成功パスだけでなく「失敗パス」を列挙する

例として、ユーザー情報を取得する非同期関数を考えます。

async function fetchCurrentUser() {
  const res = await fetch("/api/user");
  if (!res.ok) {
    throw new Error("ユーザー取得に失敗しました");
  }
  return res.json();
}
JavaScript

コードだけ見ると「失敗したら Error を投げる」ことは分かりますが、
どんな状況で、どんな種類のエラーが飛んでくるのかは分かりません。

ここで一度、頭の中でこう整理します。

ネットワーク自体が失敗したらどうなるか
HTTP ステータスが 401, 403, 500 のときはどう扱うか
レスポンス JSON が壊れていたらどうなるか

これを「なんとなく」ではなく、
「この関数の仕様」として書き出すのが、例外仕様の文書化です。

文章にするときのイメージ

例えば、こんな感じの説明になります。

fetchCurrentUser() は、以下のいずれかの結果になる:

成功時:ユーザーオブジェクトを解決する Promise を返す
失敗時:
・ネットワークエラーの場合、TypeErrorfetch が投げるもの)をそのまま伝播する
・HTTP ステータスが 2xx 以外の場合、Error を投げる(メッセージにステータスコードを含める)
・レスポンス JSON のパースに失敗した場合、SyntaxError をそのまま伝播する

ここまで書いてあると、呼び出し側は
「何を try/catch すべきか」「どんなメッセージをユーザーに見せるか」
を設計しやすくなります。


「投げる」のか「返す」のかを仕様として決める

例外で表現するスタイル

非同期関数の失敗を表現する方法は、大きく二つあります。
一つ目は「例外を投げる」スタイルです。

async function fetchUser(id) {
  const res = await fetch(`/api/users/${id}`);
  if (!res.ok) {
    throw new Error(`ユーザー取得に失敗しました: ${res.status}`);
  }
  return res.json();
}
JavaScript

この場合の仕様は、例えばこう書けます。

fetchUser(id) は以下のときに例外を投げる:

・ネットワークエラーが発生した場合:TypeError
・HTTP ステータスが 2xx 以外の場合:Error(メッセージにステータスコードを含む)
・レスポンス JSON のパースに失敗した場合:SyntaxError

呼び出し側は、こういうコードになります。

try {
  const user = await fetchUser(1);
  renderUser(user);
} catch (err) {
  showErrorMessage(err.message);
}
JavaScript

結果オブジェクトで表現するスタイル

もう一つは、「成功・失敗をオブジェクトで返す」スタイルです。

async function fetchUserSafe(id) {
  try {
    const res = await fetch(`/api/users/${id}`);
    if (!res.ok) {
      return {
        ok: false,
        error: new Error(`ユーザー取得に失敗しました: ${res.status}`),
      };
    }
    const user = await res.json();
    return { ok: true, user };
  } catch (err) {
    return { ok: false, error: err };
  }
}
JavaScript

仕様としては、こう書けます。

fetchUserSafe(id) は常に成功として解決される Promise を返す。
返り値は { ok: true, user } または { ok: false, error } のどちらか。
例外は外側には投げない(すべて error プロパティに包む)。

呼び出し側は、こうなります。

const result = await fetchUserSafe(1);
if (!result.ok) {
  showErrorMessage(result.error.message);
} else {
  renderUser(result.user);
}
JavaScript

ここで一番大事なのは
「この関数は“投げるのか”“返すのか”を、仕様としてはっきり決めて、一貫させること」
です。

どちらも混在させると、呼び出し側が地獄を見ます。


例外仕様を「コードの近く」に書く

JSDoc 風に書いてみる

ドキュメントは README に書いてもいいですが、
一番効くのは「関数のすぐ上」に書くことです。

/**
 * 現在ログイン中のユーザーを取得する。
 *
 * 成功時:
 *   - ユーザーオブジェクトを解決する Promise を返す。
 *
 * 失敗時:
 *   - ネットワークエラー: fetch が投げる TypeError をそのまま伝播。
 *   - HTTP ステータスが 2xx 以外: Error を投げる(メッセージにステータスコードを含む)。
 *   - JSON パースエラー: SyntaxError をそのまま伝播。
 */
async function fetchCurrentUser() {
  const res = await fetch("/api/user");
  if (!res.ok) {
    throw new Error(`ユーザー取得に失敗しました: ${res.status}`);
  }
  return res.json();
}
JavaScript

こうしておくと、
エディタ上で関数にカーソルを合わせたときに、
「どう失敗しうるか」が一瞬で分かります。

ここが重要です。
「例外仕様は“頭の中の暗黙ルール”ではなく、“コメントとしてコードにくっついているルール”にする。」

「呼び出し側の視点」で書く

文書化するときは、
実装者の視点ではなく「呼び出す人の視点」で書くのがコツです。

悪い書き方の例は、こうです。

fetch が失敗したらそのまま throw します。
res.ok が false のときは Error を throw します。

これだと、「で、呼び出し側は何を想定すればいいの?」が分かりません。

良い書き方は、こうです。

この関数を呼び出す側は、以下のエラーを catch しうる:

・ネットワークエラーの場合: TypeError
・HTTP エラーの場合: Error(メッセージにステータスコードを含む)
・レスポンス JSON が壊れている場合: SyntaxError

つまり、
「この関数を使う人が、try/catch の中で何を想定すべきか」を書いてあげる
イメージです。


非同期チェーン全体で「どこまで伝播させるか」を決める

どこで「握りつぶすか」を仕様として決める

非同期処理は、関数が何段階も重なります。

async function fetchCurrentUser() { ... }

async function loadUserPageData() {
  const user = await fetchCurrentUser();
  const posts = await fetchUserPosts(user.id);
  return { user, posts };
}

async function initUserPage() {
  const { user, posts } = await loadUserPageData();
  renderUser(user);
  renderPosts(posts);
}
JavaScript

ここで大事なのは、
「どのレイヤーまで例外をそのまま伝播させて、どのレイヤーで UI 向けのエラーに変換するか」
を決めることです。

例えば、こう決めることができます。

fetchCurrentUser / fetchUserPosts
→ 技術的なエラー(ネットワーク・HTTP・JSON)をそのまま投げる

loadUserPageData
→ それらをまとめて「ユーザーページの読み込みに失敗しました」という Error に包み直す

initUserPage
→ その Error を catch して、ユーザーに見せるメッセージに変換する

仕様としては、こう書けます。

fetchCurrentUser / fetchUserPosts

  • 技術的なエラーをそのまま投げる。UI 向けのメッセージは持たない。

loadUserPageData

  • 下位のエラーを受け取り、Error("ユーザーページの読み込みに失敗しました") に包み直して投げる。

initUserPage

  • loadUserPageData のエラーを catch し、ユーザー向けのエラーバナーを表示する。

ここが重要です。
「どこまでが“技術的な例外”、どこからが“ユーザー向けのエラー表現”かを、レイヤーごとに決めて文書化する。」


例外仕様とテストをセットで考える

「この仕様なら、こういうテストを書くよね」を意識する

例外仕様をちゃんと書くと、
自然と「こういうテストが必要だな」が見えてきます。

例えば、fetchUserSafe の仕様がこうだとします。

fetchUserSafe(id) は常に { ok: boolean, user?, error? } を返す。
例外は外に投げない。
ネットワークエラー・HTTP エラー・JSON エラーはすべて ok: false として返す。

この仕様に対して、テストはこうなります。

成功時:ok === trueuser が入っている
ネットワークエラー時:ok === falseerror が入っている
HTTP エラー時:同上
JSON エラー時:同上

つまり、
「例外仕様は、そのままテストケースのリストになる」
ということです。

仕様を書いていないと、
テストも「なんとなく書いた」ものになりがちです。


初心者として「例外仕様の文書化」で本当に意識してほしいこと

最後に、あなたの頭の中に置いておいてほしい問いをまとめます。

この非同期関数は、成功したとき“だけ”でなく、失敗したときにどう振る舞うかを説明できるか。
この関数は「例外を投げる」のか、「結果オブジェクトで返す」のか、一貫しているか。
呼び出し側は、どんなエラーを catch しうるのか、文章で書けるか。
どのレイヤーで技術的なエラーを UI 向けのエラーに変換するか、決めてあるか。
その仕様を、コメントやドキュメントとして「コードの近く」に残しているか。

おすすめの練習は、
自分の非同期関数を一つ選んで、関数の上にこう書いてみることです。

「この関数は、どんなときに、どんなエラー(または結果)を返すのか」

それを日本語で書き切ってから、
「じゃあ実装はこの仕様にちゃんと合っているか?」
と見直してみてください。

そのサイクルを回し始めたとき、
あなたはもう「とりあえず try/catch を書く人」ではなく、
「失敗の仕方まで設計し、言葉にできるエンジニア」 に近づいています。

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