TypeScriptでasync/awaitを共通化!再利用可能な非同期ユーティリティ設計ガイド
生徒
「TypeScriptで非同期処理を書いていると、同じようなエラーハンドリングや待ち合わせ処理が何度も出てきて、コードがごちゃごちゃになってしまいます。」
先生
「それは便利なユーティリティ関数を作るタイミングですね。非同期処理、つまりPromiseやasync/awaitを共通化することで、スッキリとした再利用可能なコードが書けるようになりますよ。」
生徒
「具体的には、どのようにして便利な道具箱を作ればいいのでしょうか?」
先生
「初心者の方でも分かりやすいように、順番に設計方法を解説していきますね!」
1. 非同期処理とPromiseの基本を知ろう
まずは非同期処理について説明します。普通のプログラムは、上から下へと順番に実行されます。しかし、インターネットからデータを取得する場合など、返事が来るまで時間がかかることがあります。この「待ち時間」の間、他の作業を止めてしまうとパソコンが固まってしまいます。そこで登場するのが、非同期処理です。
非同期処理とは、時間のかかる作業を裏側で進めておき、終わったら結果を教えてもらう仕組みのことです。TypeScriptでは、この約束をPromise(プロミス)という名前で呼びます。文字通り「未来に結果を返すという約束」という意味です。
プログラミングに慣れていない方にとって、この「後で実行される」という感覚は少し難しいかもしれませんが、料理に例えると分かりやすいです。パスタを茹でている間にソースを作りますよね。パスタが茹であがるのをじっと待ってからソースを作り始めるのではなく、並行して作業を進めるのが非同期処理のイメージです。
2. asyncとawaitでコードを読みやすくする
Promiseをより簡単に扱うための魔法の言葉がasync(エイシンク)とawait(アウェイト)です。これらを使うことで、難しい非同期処理があたかも普通の順番通りの処理のように書けるようになります。
関数にasyncをつけると、その関数は「非同期な関数」になります。そして、その中でawaitを使うと、時間のかかる処理が終わるまでその場で見守ってくれるようになります。ただし、待っている間も他のプログラムは動いているので、全体の動作が止まることはありません。
例えば、外部のサーバーからユーザーの情報を取ってくるプログラムを考えてみましょう。普通に書くと複雑になりますが、これらを使うと非常にシンプルになります。
// データを取得するシミュレーション関数
async function fetchUserData() {
console.log("データの取得を開始します...");
// 3秒待機する処理
await new Promise(resolve => setTimeout(resolve, 3000));
console.log("データの取得が完了しました!");
return { id: 1, name: "太郎" };
}
// 実行
fetchUserData();
実行結果は以下のようになります。
データの取得を開始します...
(3秒後)
データの取得が完了しました!
3. 再利用可能なユーティリティを設計する理由
なぜ「ユーティリティ(共通の道具)」を作る必要があるのでしょうか。それは、開発が進むにつれて「データの取得に失敗したときの処理」や「読み込み中に出すメッセージの管理」など、同じようなプログラムを何度も書くことになるからです。
同じことを何度も書くと、どこか一箇所を修正したいときに、全ての場所を直さなければならず、ミスが発生しやすくなります。そこで、共通のパターンを一つの関数としてまとめておき、どこからでも呼び出せるように設計するのが、プログラミングにおける再利用性の向上です。
特に非同期処理では、エラーが起きたときの対策(例外処理)が重要です。これを毎回書くのは大変なので、自動的にエラーをキャッチしてくれる便利な道具を作ると、開発がぐっと楽になります。
4. 非同期処理を包み込むラッパー関数を作る
最初におすすめするユーティリティは、エラーハンドリングを簡単にするラッパー関数です。通常、非同期処理のエラーを防ぐにはtry-catchという構文を使いますが、これが何度も出てくるとコードが読みづらくなります。
そこで、関数の実行結果を「成功したときのデータ」と「失敗したときのエラー」のセットで返してくれる道具を作ってみましょう。これにより、呼び出す側ではスッキリとした条件分岐だけで済みます。
// 共通のユーティリティ関数
async function safeAsync<T>(promise: Promise<T>) {
try {
const data = await promise;
return [data, null] as const;
} catch (error) {
return [null, error] as const;
}
}
// 使い方
async function main() {
const fetchTask = new Promise<string>((resolve, reject) => {
// 成功ならresolve、失敗ならrejectを呼ぶ
resolve("成功データ");
});
const [result, err] = await safeAsync(fetchTask);
if (err) {
console.log("エラーが発生しました:", err);
return;
}
console.log("受け取った内容:", result);
}
main();
実行結果は以下のようになります。
受け取った内容: 成功データ
このように、成功と失敗を一つの配列として受け取る形式にすることで、コードの見た目が非常に整理されます。これを設計と呼び、開発の効率を大きく左右します。
5. タイムアウト機能を共通化して制御する
インターネットの通信状況によっては、返事がいつまでも返ってこないことがあります。これを放置すると、利用者はずっと待ち続けることになってしまいます。そこで、一定時間が過ぎたら強制的に終了させる「タイムアウト機能」をユーティリティとして持っておくと非常に便利です。
これも一つの関数として用意しておくことで、どの通信処理にも簡単に制限時間を設けることができるようになります。TypeScriptの型定義をしっかり行うことで、どのようなデータが返ってくる関数にも対応させることが可能です。
// 指定した時間内に終わらなければエラーにするユーティリティ
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number) {
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error("時間切れです")), timeoutMs);
});
// どちらか早い方を採用する仕組み
return Promise.race([promise, timeoutPromise]);
}
// 使い方
async function runTask() {
const slowTask = new Promise(resolve => setTimeout(() => resolve("完了"), 5000));
try {
// 2秒でタイムアウトさせる設定
const result = await withTimeout(slowTask, 2000);
console.log(result);
} catch (e) {
console.log("メッセージ:", e.message);
}
}
runTask();
実行結果は以下のようになります。
メッセージ: 時間切れです
6. リトライ処理(再試行)の自動化
通信エラーは一時的なものであることが多いです。一回失敗しただけで諦めるのではなく、数回やり直すことで成功する可能性があります。この「やり直し」の処理を毎回書くのは面倒ですよね。そこで、自動で数回リトライしてくれるユーティリティを設計しましょう。
このユーティリティは、指定した回数だけ処理を繰り返し、すべて失敗した場合に初めてエラーを出すように作ります。これによって、システムの安定性が格段に向上します。
// 失敗しても指定回数やり直すユーティリティ
async function retry<T>(task: () => Promise<T>, count: number): Promise<T> {
let lastError: any;
for (let i = 0; i < count; i++) {
try {
console.log(`${i + 1}回目の挑戦中...`);
return await task();
} catch (error) {
lastError = error;
}
}
throw new Error("すべての試行に失敗しました: " + lastError);
}
// 実行例(わざと失敗させる)
const dangerousTask = async () => {
throw "ネットワークエラー";
};
retry(dangerousTask, 3).catch(err => console.log(err.message));
実行結果は以下のようになります。
1回目の挑戦中...
2回目の挑戦中...
3回目の挑戦中...
すべての試行に失敗しました: ネットワークエラー
7. TypeScriptで型安全な設計を意識する
ここまでの解説で、いくつかのユーティリティを見てきました。TypeScriptの最大の特徴は型(かた)があることです。型とは、そのデータが「数字なのか」「文字なのか」「特別なリストなのか」を明確に決めるルールのことです。
ユーティリティを作るときに、どんなデータでも扱えるように「ジェネリクス(Generics)」という機能を使います。コードの中で<T>のように書かれている部分がそれです。これは「後から決まる型」という意味で、これを使うことで一つの道具をどんな種類のデータにも使い回せるようになります。
プログラミング未経験の方には少し難しく感じるかもしれませんが、「中身が何であっても壊れない、頑丈な入れ物を作っている」と考えてみてください。この頑丈な設計こそが、大規模な開発でバグを防ぐ鍵となります。
8. 複数の非同期処理をまとめて管理する
最後に、複数の作業を同時に行う場合の設計について考えます。例えば、三つの異なる場所からデータを集めたいとき、一つずつ順番に待っていると時間がもったいないです。これらを一気に開始して、全員が戻ってくるのを待つ方法があります。
TypeScriptにはPromise.allという標準機能がありますが、これもユーティリティとしてラップすることで、より使いやすくカスタマイズできます。例えば、どれか一つが失敗しても他の結果は捨てないようにするといった、柔軟な設計が可能です。
このように、非同期処理をただ使うだけでなく、自分のプロジェクトに合わせて「使いやすい形に整える」ことが、プロフェッショナルなプログラミングへの第一歩です。最初は難しく感じるかもしれませんが、少しずつ自分で便利な関数を増やしていく楽しさを味わってみてください。
9. 学んだ知識を実践に活かすために
非同期処理のユーティリティ設計は、最初は真似をすることから始めましょう。今回紹介したsafeAsyncやretryなどは、実際の現場でもよく使われる形です。これらを自分のプログラムに取り入れるだけで、コードが劇的に綺麗になります。
パソコンの操作に慣れていない方でも、まずはコードをコピーして動かしてみることから挑戦してみてください。エラーが出たら、何が原因かメッセージを読んで考える。その繰り返しの先に、自由自在にプログラムを操る力が身につきます。
TypeScriptは非常に人気のある言語で、一度覚えるとウェブサイト作成からアプリ開発まで幅広く活躍できます。非同期処理という壁を乗り越えて、素晴らしいエンジニアへの道を歩んでいきましょう。