async/await とは

async/await は ES2017 で導入された構文で、Promise を扱うコードを「同期的に読めるかたち」に書き直すためのシンタックスシュガー(構文糖衣)です。裏側では Promise が動いており、async/await は Promise を消し去るものではありません。Promise の動作を前提として初めて正しく使えます。

async キーワードを関数の前に付けると、その関数は必ず Promise を返します。戻り値に return 42 と書いても、呼び出し側には Promise.resolve(42) が届きます。await はその Promise が解決するまで関数の実行を一時停止し、解決した値を取り出す式です。

基本の書き方

Promise チェーンで書いた処理と async/await で書いた処理を並べてみます。

// Promise チェーン
function fetchUser(id) {
  return fetch(`/api/users/${id}`)
    .then(response => response.json())
    .then(user => user.name);
}

// async/await
async function fetchUser(id) {
  const response = await fetch(`/api/users/${id}`);
  const user = await response.json();
  return user.name;
}

処理の流れが上から下へ一直線になり、変数への代入も自然に書けます。response.json() も非同期処理なので await が必要な点に注意しましょう。ここで await を忘れると user に Promise オブジェクトが入り、user.nameundefined になります。

エラーハンドリング

Promise チェーンでは .catch() でエラーを捕捉していましたが、async/await では try/catch を使います。

async function fetchUser(id) {
  try {
    const response = await fetch(`/api/users/${id}`);
    if (!response.ok) {
      throw new Error(`HTTP error: ${response.status}`);
    }
    return await response.json();
  } catch (err) {
    console.error('取得失敗:', err.message);
    throw err; // 呼び出し元にも伝播させる
  }
}

try/catch.catch() は機能的に等価ですが、async/await で書いたコードに .catch() を混在させると読みにくくなります。async/await で書くなら try/catch に統一するのが一般的な慣習です。

fetch は 404 や 500 でも reject しない(fetch の落とし穴)ため、response.ok のチェックを忘れずに入れましょう。

落とし穴①:await を忘れる

最も多いミスは、await を付け忘れて Promise をそのまま使ってしまうことです。

async function broken() {
  const user = fetchUser(1); // await がない!
  console.log(user.name);    // undefined。user は Promise オブジェクト
}

fetchUser は async 関数なので Promise を返します。await なしに user.name にアクセスすると undefined になります。TypeScript を使うと型推論がこのミスを検出してくれますが、素の JavaScript では実行時まで気づかないことが多いです。

落とし穴②:ループ内の await

複数のデータを取得するときに Array.forEachfor...of の中で await を使うと、処理が意図せず直列になります。

// 悪い例:3つのリクエストが直列になる(合計待機時間が 3 倍)
async function fetchAllBad(ids) {
  const results = [];
  for (const id of ids) {
    const user = await fetchUser(id);  // 前が終わるまで次を待つ
    results.push(user);
  }
  return results;
}

// 良い例:Promise.all で並行実行(待機時間は最長の 1 件分)
async function fetchAllGood(ids) {
  const promises = ids.map(id => fetchUser(id));
  return Promise.all(promises);
}

for...of の中で await すると、前のリクエストが完了してから次を開始します。3 件それぞれに 1 秒かかるなら合計 3 秒になります。Promise.all に Promise の配列を渡せば 3 件を並行に走らせられ、待機時間は 1 秒で済みます。

直列 vs 並行の実行時間比較

ただし Promise.all はいずれか 1 件でも失敗すると即座に reject します。失敗を個別に扱いたいなら Promise.allSettled を使います。

async function fetchAllSettled(ids) {
  const results = await Promise.allSettled(ids.map(id => fetchUser(id)));
  return results.map(r => (r.status === 'fulfilled' ? r.value : null));
}

async 関数の戻り値

async 関数は必ず Promise を返します。これは呼び出し側にも影響します。

async function getUser() {
  return { name: 'Alice' };
}

// 間違い:Promise を受け取っているので user.name は undefined
const user = getUser();
console.log(user.name);

// 正しい:await で解決する
const user = await getUser();
console.log(user.name); // 'Alice'

最上位の await(Top-level await)は ES2022 以降の ES モジュールでのみ使えます。それ以外の環境では async 関数でラップして await する必要があります。

まとめ

  • async/await は Promise のシンタックスシュガー。Promise の動作を理解していることが前提です
  • async 関数は必ず Promise を返します。戻り値が return 42 でも呼び出し側には Promise<number> が届きます
  • エラーハンドリングは try/catch に統一すると読みやすくなります
  • await を付け忘れると Promise オブジェクトを直接操作してしまいます
  • ループ内の await は処理が直列になります。並行でよいなら Promise.all を使いましょう
  • 呼び出し側でも await するか、.then() で繋ぐ必要があります

async/await はあくまで「読みやすさ」のための道具です。コードが同期的に見えても非同期に動いており、Promise のエラー伝播や並行性の設計はそのまま残ります。構文の便利さに頼りすぎず、Promise の挙動を正しく把握した上で使うのが正しいアプローチです。