TypeScript非同期処理のバグを防ぐ!Promiseとasync/awaitの基礎から対策まで
生徒
「TypeScriptで通信や読み込みを待つ処理を書いているのですが、順番がバラバラになってしまって困っています。」
先生
「それは非同期処理における典型的な悩みですね。TypeScriptでは、Promiseやasync、awaitという仕組みを正しく使う必要があります。」
生徒
「非同期処理って何だか難しそうです。初心者でも失敗しない方法はありますか?」
先生
「はい。よくあるバグのパターンを知っておけば、未然に防ぐことができます。一緒に学んでいきましょう!」
1. 非同期処理とは何かを身近な例で理解しよう
プログラミングの世界には、非同期処理という言葉があります。これを聞くと難しく感じるかもしれませんが、私たちの日常生活に置き換えると非常に簡単です。例えば、レストランで料理を注文する場面を想像してください。注文した後に、料理が運ばれてくるまで席でじっと動かずに待っているのは効率が悪いですよね。料理を作ってもらっている間に、お冷を飲んだり、スマホを見たり、友達と楽しくおしゃべりしたりすることができます。これが非同期処理のイメージです。
コンピュータの世界でも、インターネットから大きなデータをダウンロードしたり、データベースに情報を保存したりするときには時間がかかります。その「待ち時間」に他の仕事を同時進行させる仕組みを非同期処理と呼びます。TypeScriptはこの仕組みを非常に得意としていますが、正しく指示を出さないと、料理が完成していないのに食べようとしてしまうようなミス、つまりバグが発生してしまいます。
まずは、非同期処理が「後回しにされる予約」のようなものだと考えてください。この予約をどう管理するかが、プログラムを正しく動かすための鍵となります。
2. Promise(プロミス)は未来の約束
非同期処理を扱う上で最も重要なキーワードがPromiseです。英語で約束という意味ですが、プログラミングでも「今はまだ結果が出ていないけれど、将来必ず結果を渡すよ」という約束の状態を表します。Promiseには三つの状態があります。一つ目は「待機中」、二つ目は無事に成功した「完了」、三つ目は何らかの理由で失敗した「拒否」です。
例えば、友達に「明日お菓子を買ってくるね」と約束したとします。その瞬間はまだお菓子はありませんが、約束だけが存在します。明日になって本当にお菓子を渡せれば成功ですし、お店が閉まっていて買えなければ失敗となります。TypeScriptのプログラムも、この約束の状態を確認しながら進んでいきます。Promiseを理解することで、データの読み込みが終わるのを待ってから次の処理に移るという制御が可能になります。
3. asyncとawaitでコードを読みやすくする
以前のプログラムでは、このPromiseの結果を受け取るために少し複雑な書き方をしていました。しかし、最近のTypeScriptではasyncとawaitという便利な道具を使います。これを使うと、非同期な処理をまるで上から下へ順番に実行される普通の処理のように書くことができます。これを「同期的な見た目で書ける」と言ったりします。
関数を作るときに頭にasyncをつけると、その関数の中では魔法の言葉awaitが使えるようになります。awaitは「この処理が終わるまで、ちょっとここで待っていてね」とプログラムに命令する役割を持っています。これによって、インターネットからのデータ取得が終わる前に次の行が実行されてしまうというトラブルを防ぐことができます。初心者の方は、まずはこのセットを使うことを意識しましょう。
async function fetchUserDetail() {
console.log("データの取得を開始します...");
// 2秒待機する擬似的な非同期処理
await new Promise(resolve => setTimeout(resolve, 2000));
console.log("データの取得が完了しました!");
}
fetchUserDetail();
データの取得を開始します...
(2秒待つ)
データの取得が完了しました!
4. 典型的なバグ:awaitを忘れてしまうミス
TypeScriptの非同期処理で最も多く発生するバグは、awaitを書き忘れてしまうことです。本来であれば完了を待たなければならない処理に対して、待ち合わせを指示し忘れると、プログラムは結果を待たずに次の行へ進んでしまいます。その結果、まだ中身が空っぽの状態のデータを参照しようとして、エラーが発生したり、予期しない動作をしたりします。
例えば、電子レンジで温めが終わっていないお弁当を取り出して食べようとするようなものです。中身はまだ冷たいままですよね。プログラムでも、データの準備ができていないのに画面に表示しようとすると、何も映らなかったり、変な数字が出てきたりします。TypeScriptは優秀なので、型チェックという機能で「これはPromiseの状態だから待つ必要がありますよ」と教えてくれることもありますが、自分で意識して書くことが大切です。
async function getGreeting() {
return "こんにちは!";
}
async function showMessage() {
// ここでawaitを忘れるとPromiseオブジェクト自体が表示されてしまう
const message = getGreeting();
console.log("メッセージを表示します:", message);
}
showMessage();
メッセージを表示します: Promise { <pending> }
5. 対策:戻り値の型を常に意識する
先ほどのバグを防ぐための最良の方法は、関数の戻り値(関数が返してくれる結果の種類)を意識することです。TypeScriptでは、非同期な関数の戻り値は必ずPromise<型>という形になります。もし変数の型を確認して、このPromiseという文字が入っていたら、「あ、これはawaitをつけて待たないといけないんだな」と判断することができます。
開発ツールを使っていると、マウスをコードの上に置くだけで型を表示してくれます。未経験の方には型と聞くと難しく感じるかもしれませんが、「この箱の中身は何か」を定義しているラベルのようなものだと思ってください。ラベルにPromiseと書いてあれば、それはまだ中身が入っていない予約票のようなものです。しっかり中身を取り出すためにawaitを使いましょう。
6. 典型的なバグ:エラーハンドリングの不足
次に多いバグは、エラーが起きたときのことを考えていないという点です。インターネットの通信は常に成功するとは限りません。急に電波が悪くなったり、相手のサーバーが止まっていたりすることもあります。非同期処理において、エラーが発生した状態を「拒否(Rejected)」と呼びますが、この状態を放置するとプログラム全体が止まってしまうことがあります。
非同期処理のエラー対策には、try...catchという構文を使います。「とりあえずこの処理をやってみて(try)、もしダメだったらこっちの処理で助けて(catch)」という意味です。これを用意しておくことで、もし通信に失敗しても「通信に失敗しました。もう一度試してください」といった優しいメッセージをユーザーに表示できるようになります。バグのないプログラムとは、失敗したときのフォローがしっかりしているプログラムのことです。
async function loadData() {
try {
console.log("サーバーに接続中...");
// 失敗をシミュレーション
throw new Error("接続エラーが発生しました");
} catch (error) {
console.log("エラーをキャッチしました:", error.message);
}
}
loadData();
サーバーに接続中...
エラーをキャッチしました: 接続エラーが発生しました
7. 典型的なバグ:ループ内での非同期処理
初心者の方がよくやってしまう難しいバグに、繰り返し処理(ループ)の中での非同期処理があります。例えば、10人の友達に順番にメールを送る処理を書きたいときに、forEachという繰り返し機能の中でawaitを使おうとすると、意図した通りに動かないことがあります。これは、forEachが非同期処理を待つように設計されていないためです。
この場合、全員にメールを送り終わるのを待たずに、プログラムが勝手に「全員に送り終わりました!」と先に表示してしまいます。これを解決するには、for...ofという別の繰り返し構文を使うか、Promise.allという「全員終わるまでまとめて待つ」という特別な機能を使う必要があります。順番に一人ずつ処理したいのか、それとも全員分を一気にまとめて処理したいのかによって、書き方を選ぶのがコツです。
async function processNumbers() {
const numbers = [1, 2, 3];
console.log("処理を開始します");
// for...of文を使えば一つずつ確実に順番を待てる
for (const num of numbers) {
await new Promise(resolve => setTimeout(resolve, 500));
console.log("数字を処理しました:", num);
}
console.log("すべての処理が完了しました");
}
processNumbers();
処理を開始します
数字を処理しました: 1
数字を処理しました: 2
数字を処理しました: 3
すべての処理が完了しました
8. 対策:Promise.allで効率化する
バグではありませんが、非同期処理をより使いこなすための知恵としてPromise.allの紹介です。一つひとつの処理をawaitで順番に待っていると、合計の待ち時間がとても長くなってしまいます。例えば、3つのファイルをダウンロードするのに、1つずつ待っていたら3倍の時間がかかりますよね。これを同時に開始して、全部終わるまで一緒に待つのがPromise.allの役目です。
これは、複数の注文を一度にキッチンへ通すようなものです。個別に注文して一つずつ待つよりも、まとめて注文して全部揃うのを待つほうが効率的です。ただし、この方法を使うときは「一つでも失敗すると全部失敗扱いになる」という特徴があるため、注意が必要です。状況に合わせて、一個ずつ丁寧に待つのか、まとめて一気に進めるのかを判断できるようになると、初心者脱出です。
9. 型安全な非同期処理のために
TypeScriptを使う最大のメリットは、型によって間違いを未然に防げることです。非同期処理においても、関数の戻り値にしっかりと型を指定することを習慣にしましょう。例えば、ユーザー情報を取得する関数であれば、Promise<User>のように、将来的にどんなデータが返ってくるのかを明示します。
これにより、データを受け取った後に「名前を表示しようとしたけれど、実はデータの中に名前なんて項目はなかった」というミスを、コードを書いている最中に見つけることができます。パソコンをあまり触ったことがない方でも、TypeScriptが提示してくれる赤い波線(エラー表示)に注目するだけで、大きなトラブルを回避できるようになります。非同期処理は目に見えない「時間」を扱うので、型という確かな道しるべを大切にしてください。