· yuta

Promiseとasync/awaitの正体を理解する

#javascript#typescript#async

async / await はJavaScript・TypeScriptを書いていればほぼ毎日目にする構文です。「非同期処理を書くときにおまじないのように付けるもの」という理解のまま使っている人も多いのではないでしょうか。今回は、なぜこの構文が必要になったのかを、コールバック → Promise → async/await という登場の歴史をたどりながら整理します。

例として、以前の記事でも登場した架空のオンライン料理教室アプリ CookMate を使います。「レッスン詳細ページを開いたら、サーバーからレッスン情報を取得する」という場面を考えます。

そもそも非同期処理とは何か

JavaScriptはシングルスレッド、つまり一度に一つの処理しか実行できません。もしAPI通信のようにレスポンスが返ってくるまで数百msかかる処理を同期的に(その場で完了を待つ形で)書いてしまうと、レスポンスが返るまでブラウザ全体が固まってしまいます。

そこでJavaScriptは、時間のかかる処理を「今は実行だけ依頼しておいて、結果は後で受け取る」という非同期処理として扱います。この「後で受け取る」部分をどう書くかが、今回のテーマです。

第1世代:コールバックによる非同期処理

最も原始的な方法は、「終わったら呼んでね」という関数(コールバック)を渡すやり方です。

function fetchLesson(lessonId: string, callback: (lesson: Lesson) => void) {
  fetch(`/api/lessons/${lessonId}`)
    .then((res) => res.json())
    .then((data) => callback(data));
}

fetchLesson('lesson-1', (lesson) => {
  console.log(lesson.title);
});

これだけなら読めますが、問題は**「レッスン情報を取ってから、そのレッスンの講師情報を取り、さらにその講師の他のレッスン一覧を取る」**のように、非同期処理が依存関係を持って連鎖するときです。

fetchLesson('lesson-1', (lesson) => {
  fetchInstructor(lesson.instructorId, (instructor) => {
    fetchOtherLessons(instructor.id, (otherLessons) => {
      console.log(otherLessons); // ❌ どんどん右にネストしていく
    });
  });
});

処理が増えるたびにネストが深くなっていくこの状態は コールバック地獄(callback hell) と呼ばれます。エラーハンドリングもそれぞれのコールバックで個別に書く必要があり、可読性・保守性ともに悪化していきます。

第2世代:Promiseで「非同期処理の結果」を値として扱う

この問題を解決するために導入されたのが Promise です。Promiseは「今はまだ終わっていないが、いずれ結果が出る処理」を表すオブジェクトで、次の3つの状態のいずれかを持ちます。

コールバックを直接渡すのではなく、Promiseを返す関数として書き直すとこうなります。

function fetchLesson(lessonId: string): Promise<Lesson> {
  return fetch(`/api/lessons/${lessonId}`).then((res) => res.json());
}

呼び出し側は .then() でfulfilled時の処理、.catch() でrejected時の処理を**チェーン(数珠つなぎ)**にできます。

fetchLesson('lesson-1')
  .then((lesson) => fetchInstructor(lesson.instructorId))
  .then((instructor) => fetchOtherLessons(instructor.id))
  .then((otherLessons) => console.log(otherLessons))
  .catch((error) => console.error('取得に失敗しました', error)); // ✅ エラー処理は最後に1箇所

ネストが横に深くなる代わりに縦に並ぶようになり、エラーハンドリングも .catch() 一箇所に集約できるようになりました。これはコールバック地獄に対する大きな改善ですが、.then() の連鎖はまだ「同期処理っぽい見た目」ではなく、変数に一時保存したい場合などはやや書きにくさが残ります。

第3世代:async/awaitは「Promiseの糖衣構文」

そこで登場するのが async / await です。これは**Promiseを置き換える新しい仕組みではなく、Promiseを同期処理のように書けるようにする構文糖(syntax sugar)**です。中身でやっていることはPromiseと同じです。

先ほどの連鎖を書き直すとこうなります。

async function loadRelatedLessons(lessonId: string) {
  try {
    const lesson = await fetchLesson(lessonId);
    const instructor = await fetchInstructor(lesson.instructorId);
    const otherLessons = await fetchOtherLessons(instructor.id);
    console.log(otherLessons);
  } catch (error) {
    console.error('取得に失敗しました', error);
  }
}

.then() の連鎖が消え、上から下に読むだけで処理の流れを追えるようになりました。ポイントは次の2つです。

await は「Promiseを待つ間、JavaScript自体を止めている」わけではない点に注意が必要です。待っている間、他のイベント処理(クリックやタイマーなど)は普通に実行されます。止まって見えるのは、あくまでその async 関数の中の処理だけです。

async関数は必ずPromiseを返す

見落としがちなポイントとして、async を付けた関数は、return した値をそのまま返すのではなく、その値でfulfilledしたPromiseで包んで返します。

async function getTitle() {
  return 'RDBMSを自作して学ぶ';
}

const result = getTitle();
console.log(result); // Promise { 'RDBMSを自作して学ぶ' } ← 文字列ではなくPromise

呼び出し側で中身の値が欲しい場合は、await するか .then() を使う必要があります。async を付けた時点で「同期関数のふりをした非同期関数」になる、と覚えておくと迷いません。

try/catchでエラーハンドリングする

Promiseの .catch() に対応するのが try/catch です。await しているPromiseがrejectedになると、その行で例外がthrowされたのと同じ扱いになります。

async function createLesson(title: string) {
  try {
    const lesson = await api.post('/lessons', { title });
    return lesson;
  } catch (error) {
    // fetchやAPIエラーはここでまとめて捕捉できる
    console.error('レッスン作成に失敗しました', error);
    throw error;
  } finally {
    // 成功・失敗どちらでも必ず実行される後始末はここに書く
    console.log('リクエスト完了');
  }
}

try の中に書いた await がどこで失敗しても、同じ catch ブロックに処理が飛んでくるのが .then().then().catch() との大きな違いです。連鎖のどこかにピンポイントで違う処理を挟みたい場合を除けば、try/catch の方が見通しよく書けます。

落とし穴:直列実行と並列実行

await を使うと素直に書けるようになる反面、書いた順番のまま1つずつ実行されることを忘れると、無駄に遅いコードになりがちです。

たとえばレッスン詳細ページで、「レッスン情報」と「レビュー一覧」という互いに依存しない2つのAPIを呼ぶ場合を考えます。

// ❌ 直列実行:レッスン取得が終わるまでレビュー取得が始まらない
const lesson = await fetchLesson(lessonId);
const reviews = await fetchReviews(lessonId);
// 合計時間 ≒ レッスン取得時間 + レビュー取得時間

このコードは正しく動きますが、レビューの取得はレッスンの取得結果に依存していないにもかかわらず、律儀に順番を待ってしまっています。依存関係のない非同期処理は Promise.all でまとめて開始するのが定石です。

// ✅ 並列実行:両方同時に開始し、両方揃うのを待つ
const [lesson, reviews] = await Promise.all([
  fetchLesson(lessonId),
  fetchReviews(lessonId),
]);
// 合計時間 ≒ 2つのうち遅い方の取得時間

「後の処理が前の処理の結果を必要とするか」を意識し、依存がなければ並列化できないかを検討する、という習慣をつけておくとよいです。

まとめ

await の後ろで何が起きているか、そしてそれがPromiseの .then() / .catch() と何が同じで何が違うのかを意識できると、非同期処理まわりのバグや、意図しないパフォーマンス低下に気づきやすくなります。