JavaScript / TypeScript公開 約10分

Promise.allSettledで一部失敗の結果を残す:項目と完了順を分ける

複数の情報をまとめて読み込むとき、一件の失敗で成功分まで消えていませんか。Promise.allSettledで各結果を元の項目へ対応させ、0件の成功と取得失敗を分けます。完了順を逆転させた模擬処理で表示用データを確かめます。

この記事の目次
  1. 全部そろうことが必要か、項目ごとに使えるか
  2. 入力順を住所にして、結果を元の項目へ戻す
  3. 成功した0件を、失敗と同じ空欄にしない
  4. 早く終わった結果へ、先頭の名前を付けない
  5. 待ち続ける処理と、取り消したい処理は別に設計する
  6. 全成功・一部失敗・全失敗の行を見比べる

会場情報、案内文、写真を別々に取得する画面で、写真だけの失敗なのに全体を「読み込めませんでした」にしていませんか。ほかの情報だけでも利用できるなら、成功した項目を残し、取得できなかった項目を明示する選択があります。

Promiseとasync/await、配列のmapが分かる人向けです。架空の施設案内を題材に、Node.js 24系で通信を模した処理を実行します。HTTPサーバーへは接続せず、三つの独立した処理の結果を、表示に使うデータへまとめる部分を作ります。

全部そろうことが必要か、項目ごとに使えるか

Promise.allは、すべて成功した場合の値をまとめたい場面に合います。一つが拒否されると、全体のPromiseも拒否されます。全部そろわなければ計算できない入力なら、その性質が判断に合う場合があります。

今回の三項目は独立しています。会場情報を読むのに写真の取得成功は不要で、案内文が0件でも「取得できたうえで登録がない」と判断できます。allSettledは各処理が成功か失敗で決着するまで待ち、項目ごとの結果を返します。MDNのallSettledの説明に沿い、失敗を配列の中の一結果として扱います。

条件 必要な情報 今回の方針
三項目とも成功 各項目の値 それぞれの件数を表示
写真だけ失敗 他の二項目と写真の失敗 成功分を残し、写真の失敗を明示
案内文が0件で成功 取得成功と件数0 正常な0件として表示
全項目が失敗 取得できなかった事実 全体も失敗と分かる集計を出す

「何が返っても画面を成功扱いにする」設計ではありません。完了したという意味と、すべて取得できたという意味を分けるために、成功数と失敗数も数えます。

入力順を住所にして、結果を元の項目へ戻す

次をpartial.mjsへ保存し、node partial.mjsで実行します。タイマーの数値は完了順を変えるための設定で、実際の通信時間や性能を示しません。短い写真処理が先に決着しても、出力は会場情報・案内文・写真の順にします。

import { pathToFileURL } from 'node:url';
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));

export async function collect(jobs) {
  const results = await Promise.allSettled(
    jobs.map(job => Promise.resolve().then(() => job.run()))
  );
  return results.map((result, index) => {
    const { id, label } = jobs[index];
    return result.status === 'fulfilled'
      ? { id, label, status: 'ready', count: result.value }
      : { id, label, status: 'failed' };
  });
}

export async function scenario(failedIds) {
  const completionOrder = [];
  const specs = [
    { id: 'venue', label: '会場情報', delay: 60, count: 2 },
    { id: 'notes', label: '案内文', delay: 30, count: 0 },
    { id: 'photos', label: '写真', delay: 10, count: 3 }
  ];
  const jobs = specs.map(spec => ({
    id: spec.id, label: spec.label,
    async run() {
      await wait(spec.delay);
      completionOrder.push(spec.id);
      if (failedIds.includes(spec.id)) throw new Error('模擬の取得失敗');
      return spec.count;
    }
  }));
  const rows = await collect(jobs);
  return { rows, completionOrder };
}

async function main() {
  for (const failedIds of [[], ['photos'], ['venue', 'notes', 'photos']]) {
    const { rows, completionOrder } = await scenario(failedIds);
    console.log('完了順:' + completionOrder.join(' → '));
    for (const row of rows) {
      console.log(row.status === 'ready'
        ? `${row.label}:${row.count}件`
        : `${row.label}:取得できませんでした`);
    }
    const ready = rows.filter(row => row.status === 'ready').length;
    console.log(`取得成功${ready}項目、取得失敗${rows.length - ready}項目`);
  }
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
  await main();
}

最初の実行結果は会場情報2件、案内文0件、写真3件で、取得成功3項目・失敗0項目です。二回目は写真だけ取得失敗となり、成功2項目・失敗1項目。三回目は三つとも失敗し、成功0項目・失敗3項目になります。

allSettledが返す配列は完了順ではなく、渡したPromiseの順です。そのため同じindexのjobsからidとlabelを読むと、結果を元の項目へ戻せます。表示用データにはidも残し、後の処理でラベルの綴りを識別子として使う必要をなくします。

Promise.resolve().thenでrunを呼ぶのは、runがPromiseを返す前に同期的にthrowした場合も、拒否された一件として扱うためです。今回のasync関数は既に拒否へ変換されますが、collectの呼出し元が普通の関数に変わっても、一件の例外でjobsの列挙自体が途中停止しない形にしています。

成功した0件を、失敗と同じ空欄にしない

第一の失敗は、if (result.value)で成功かどうかを判断することです。案内文の0は偽として評価されるため、成功したのに失敗側へ進みます。修正ではvalueの内容ではなく、result.status === 'fulfilled'を先に調べます。

fulfilledの結果にはvalue、rejectedの結果にはreasonがあります。どちらかを確認せずに値を読むのではなく、判別してから取り出します。修正後、写真が失敗している回でも案内文は「0件」のままかを照合してください。

失敗をcatchで0へ置き換える方法も、今回の目的に合いません。0を返したPromiseは成功扱いになるので、「登録がない」と「分からない」を区別できなくなります。この点はfetchの失敗と0件を分ける記事と共通です。allSettledの前段にある取得関数も、必要な失敗を拒否として伝える契約にします。

早く終わった結果へ、先頭の名前を付けない

第二の失敗は、各処理の終了時に共有配列へpushし、その配列と元のjobsを同じindexで結ぶことです。この教材では写真が最初に決着するため、写真の値を会場情報として表示してしまいます。すべて成功していても、情報の対応が壊れます。

修正は完成例のようにallSettledの戻り値の順序を使うか、完了時にidと結果を一組で保存することです。ここでは前者を選びます。specsのdelayを入れ替えても、rowsのidはvenue、notes、photosのままで、各件数も変わらないことを確認します。

入力配列自体を待機中に並べ替えると、後からjobs[index]を読む設計は崩れます。この関数では、呼出し中にjobsを変更しない約束です。外から変更される配列を受け取る構成なら、開始時にid・label・runの一覧を固定してから処理します。

待ち続ける処理と、取り消したい処理は別に設計する

allSettledは、終わらないPromiseを勝手に失敗へ変えません。一件が決着しなければ、まとめた結果も返りません。タイムアウトや中止が必要なら、取得処理側にその契約を用意します。失敗をまとめるAPIと、処理を止めるAPIは別の役割です。

また、Promise.allの説明にある早い拒否も、ほかの処理を自動で取り消す操作ではありません。allからallSettledへ替える前に、「全体を失敗にするか」「残りを続けるか」「成功分を見せるか」をそれぞれ決めます。

一方、先に終わった項目を直ちに画面へ出したいなら、全件待つこの完成例とは異なる表示管理が必要です。認証の後で個人データを読むような依存関係も、独立した三件として同時開始できません。古い検索結果の競合を防ぐ記事は別の検索同士の新旧を扱い、今回の一回の取得内の部分成功とは分けて考えます。

全成功・一部失敗・全失敗の行を見比べる

15分程度の課題では、三回の出力を保存し、案内文の正常な0件、写真だけの失敗、全失敗の集計を照合します。追加でjobsを空にしたawait collect([])は空配列を返すのが解答です。データがないことを、例外として扱う必要があるかは呼出し側で決めます。

さらにrunが直接throwする一件を渡し、statusがfailedになることを確かめます。完了順を変えてもidと件数が対応し、失敗した項目だけが取得不能と分かれば、この集約処理を説明できます。JavaScriptの無料レッスンでDOM表示へつなぐ場合も、画面を作る前にrowsの三状態を確認しておくと、表示側で失敗を隠す変更に気付けます。

TRY A LESSON

記事の内容を、課題で確かめる

JavaScriptでフォームを作る練習 →

JavaScriptの学習内容 →

記事についてのお問い合わせ

記載内容へのご質問や訂正のご連絡は、hello@junkbranding.comまでお願いします。

← 記事一覧に戻る