Promiseとasync/awaitの正体を理解する
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つの状態のいずれかを持ちます。
- pending(実行中・未確定)
- fulfilled(成功して値が確定した)
- rejected(失敗してエラーが確定した)
コールバックを直接渡すのではなく、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つです。
async: 関数の前に付けると、その関数は必ずPromiseを返す関数になるawait: Promiseがfulfilled(またはrejected)になるまで、その行で処理を一時停止し、fulfilledなら中身の値を、rejectedなら例外を返す
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つのうち遅い方の取得時間
「後の処理が前の処理の結果を必要とするか」を意識し、依存がなければ並列化できないかを検討する、という習慣をつけておくとよいです。
まとめ
- JavaScriptはシングルスレッドなので、時間のかかる処理は非同期として扱う必要がある
- 非同期処理は コールバック → Promise → async/await の順で進化してきた
- コールバックは依存関係のある処理が連鎖するとコールバック地獄になりやすい
- Promiseは非同期処理の結果を「pending / fulfilled / rejected」を持つ値として扱い、
.then()/.catch()でチェーンできる - async/awaitはPromiseを同期処理のように書ける構文糖で、中身の仕組みはPromiseと同じ
asyncを付けた関数は常にPromiseを返すawaitで待っているPromiseがrejectedすると例外としてcatchに飛ぶ- 依存関係のない非同期処理を
awaitで直列に並べると無駄に遅くなるため、Promise.allで並列化できないか検討する
await の後ろで何が起きているか、そしてそれがPromiseの .then() / .catch() と何が同じで何が違うのかを意識できると、非同期処理まわりのバグや、意図しないパフォーマンス低下に気づきやすくなります。