🎆

初心者がTypeScriptで学ぶasync/await

に公開

はじめに

現代のWeb開発において、非同期処理は避けて通れない重要な概念です。APIからのデータ取得、ファイルの読み書き、タイマー処理など、時間がかかる操作を行う際に、アプリケーションの応答性を維持するために非同期処理が必要となります。
TypeScriptにおける非同期処理の実装方法はいくつかありますが、中でもasync/awaitは読みやすく、保守性の高いコードを書くための強力な機能です。この記事では、非同期処理の基本からTypeScriptにおけるasync/awaitの使い方、そして実践的なパターンまでを初心者向けに解説します。
この記事を読むことで、次のことが理解できます!

  • 非同期処理とは何か、そしてなぜ必要なのか
  • Promiseの基本概念と使い方
  • async/awaitの構文と動作原理
  • 効果的なエラーハンドリング
  • 実践的なコード例

非同期処理とは何か

JavaScriptは基本的にシングルスレッドで動作します。つまり、一度に1つのタスクしか実行できません。しかし、データの取得やファイルの読み込みなど、時間のかかる操作を行う際に、処理が完了するまで他の操作をブロックしてしまうと、ユーザー体験が著しく低下します。
非同期処理を使うと、時間のかかる操作を「バックグラウンド」で実行しながら、メインのプログラムは他の処理を続けることができます。処理が完了したら、その結果を受け取って次の処理を行うという仕組みです。

従来のコールバックパターン

JavaScriptでは伝統的に、非同期処理はコールバック関数を使って実装されてきました:

fuction fetchData(callback: (error: Error | null, data: any) => void) {
    setTimeout(() => {
       if (/* 成功した場合 */) {
         callback(null, "取得したデータ");
       } else {
         callback(new Error("データの取得に失敗しました"), null);
       }
    }, 1000);
}
// 使用例
fetchData((error, data) => {
  if (error) {
    console.error("エラーが発生しました:", error);
    return;
  }
  console.log("データ:", data);
  
  // ネストされたコールバック(コールバックヘル)
  fetchData((error, data2) => {
    if (error) {
      console.error("エラーが発生しました:", error);
      return;
    }
    console.log("データ2:", data2);
    
    // さらにネスト...
  });
});

このパターンの問題点は、複数の非同期処理を連鎖させる必要がある場合に「コールバックヘル」と呼ばれる深くネストされた構造になりがちなことです。コードの可読性と保守性が低下します。

Promiseの基本

コールバックヘルの問題を解決するために導入されたのがPromiseです。Promiseは非同期処理の最終的な完了(または失敗)とその結果の値を表すオブジェクトです。

Promiseの作成と使用

TypeScriptでPromiseを作成するには、Promiseコンストラクタを使用します:

function fetchData(): Promise<string> {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      // 非同期処理
      const success = true; // 成功か失敗かのフラグ

      if (success) {
        resolve("取得したデータ"); // 成功時
      } else {
        reject(new Error("データの取得に失敗しました")); // 失敗時
      }
    }, 1000);
  });
}

// 使用例
fetchData()
  .then(data => {
    console.log("データ:", data);
    return fetchData(); // 別のPromiseを返す
  })
  .then(data2 => {
    console.log("データ2:", data2);
  })
  .catch(error => {
    console.error("エラーが発生しました:", error);
  });

Promiseを使うことで、コールバックヘルの問題は改善されますが、.then()による連鎖が長くなると、まだ可読性に課題が残ります。

async/awaitの基本

async/awaitPromiseをさらに直感的に扱うことができます。この構文を使うと、非同期コードがあたかも同期コードのように見え、理解しやすくなります。

async/await基本構文

async function fetchDataAsync(): Promise<string> {
  try {
    const data = await feathData(); // Promiseが解決されるまで待機
    console.log("データ:", data);

    const data2 = await feathData(); // 次のPromiseが解決されるまで待機
    console.log("データ2:", data2);

    return "すべての処理が完了しました";
  } catch (error) {
    console.error("エラーが発生しました:", error);
    throw error; // エラーを再スロー
  }
}

// 使用例
fetchDataAsync()
  .then(result => console.log(result))
  .catch(error => console.error("キャッチされたエラー:", error));

この例では、async関数内でawaitキーワードを使用して、Promiseが解決されるまで処理を一時停止しています。処理が再開されると、Promiseの結果が変数に代入されます。エラーハンドリングには通常のtry/catchブロックを使用できます。

asyncとawaitの動作原理

asyncawaitがどのように動作するのか、基本的な仕組みを理解しましょう:

  1. asyncキーワードを関数の前につけると、その関数は常にPromiseを返します。
  2. Promiseでない値を返した場合、自動的にPromise.resolveでラップされます。
  3. 例外がスローされた場合、自動的にPromise.rejectでラップされます。
  4. awaitキーワードはPromiseが解決されるまで関数の実行を一時停止します。
  5. Promiseが解決されると、その結果がawait式の戻り値になります。
  6. Promiseが拒否された場合、例外がスローされ、try/catchブロックでキャッチできます。
typescript// これは...
async function example() {
  return "結果";
}

// ...これと同等です
function example() {
  return Promise.resolve("結果");
}

実践的なasync/await

ここからは、TypeScriptでasync/awaitを使った実践的な例を見ていきましょう。

基本的なHTTPリクエスト

fetch APIを使った基本的なHTTPリクエストの例です:

typescriptinterface User {
  id: number;
  name: string;
  email: string;
}

async function getUser(id: number): Promise<User> {
  try {
    const response = await fetch(`https://api.example.com/users/${id}`);
    
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    
    const user: User = await response.json();
    return user;
  } catch (error) {
    console.error("ユーザー取得中にエラーが発生しました:", error);
    throw error;
  }
}

// 使用例
async function displayUser(id: number): Promise<void> {
  try {
    const user = await getUser(id);
    console.log(`ユーザー: ${user.name} (${user.email})`);
  } catch (error) {
    console.error("ユーザー表示中にエラーが発生しました:", error);
  }
}

displayUser(1);

この例では、fetch APIを使用してユーザーデータを取得し、結果を処理しています。async/awaitにより、コードが同期的に見えるため理解しやすくなっています。

エラーハンドリング

async/awaitを使用する際の効果的なエラーハンドリング戦略を見ていきましょう。

try/catchによるエラーハンドリング

最も一般的なアプローチは、try/catchブロックを使用することです:

typescriptasync function fetchData(): Promise<string> {
  try {
    const response = await fetch('https://api.example.com/data');
    
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    
    const data = await response.json();
    return data;
  } catch (error) {
    console.error('データ取得中にエラーが発生しました:', error);
    throw error; // エラーを再スロー
  }
}

エラーの伝播

エラーを呼び出し元に伝播させることで、エラー処理を集中化できます:

typescriptasync function fetchData(): Promise<string> {
  const response = await fetch('https://api.example.com/data');
  
  if (!response.ok) {
    throw new Error(`HTTP error! status: ${response.status}`);
  }
  
  return await response.json();
}

async function processData(): Promise<void> {
  try {
    const data = await fetchData();
    console.log(data);
  } catch (error) {
    // fetchData()からスローされたエラーをここでキャッチ
    console.error('データ処理中にエラーが発生しました:', error);
  }
}

エラーハンドリングの混合アプローチ

Promise構文とasync/await構文を組み合わせたエラーハンドリングも可能です:

typescriptasync function fetchData(): Promise<string> {
  // エラーをスローする可能性がある
  const response = await fetch('https://api.example.com/data');
  
  if (!response.ok) {
    throw new Error(`HTTP error! status: ${response.status}`);
  }
  
  return await response.json();
}

function handleError(error: any): void {
  console.error('エラーハンドラー:', error);
}

async function processData(): Promise<void> {
  // catch()を使用してエラーをハンドリング
  const data = await fetchData().catch(handleError);
  
  if (data) {
    console.log(data);
  }
}

この方法では、await式でPromiseの結果を取得しながらも、.catch()を使ってエラーを処理しています。

async/awaitのベストプラクティス

TypeScriptでasync/awaitを使用する際のベストプラクティスをいくつか紹介します:

  1. 適切なエラーハンドリングを常に実装する:
  • 非同期処理では、エラーが発生する可能性が高いため、try/catchブロックを使用して適切に処理しましょう。
  1. 不必要なawaitを避ける:
  • awaitの使いすぎはパフォーマンスのボトルネックとなる可能性があります。複数の独立した非同期処理がある場合は、Promise.all()を使用しましょう。
  1. 型情報を明示する:
  • TypeScriptの型システムを活用して、Promiseの戻り値の型を明示しましょう。これにより、コードの可読性と安全性が向上します。
typescriptasync function fetchUser(): Promise<User> {
  // ...
}
  1. 非同期操作の結果をキャッシュする:
  • 同じ非同期操作を繰り返し行う場合は、結果をキャッシュすることでパフォーマンスを向上させましょう。
  1. エラーハンドリングのメカニズムを統一する:
  • アプリケーション全体で一貫したエラーハンドリングの方法を使用しましょう。
  1. トップレベルでのawaitの使用に注意する:
  • モジュールレベルでのawait(トップレベルawait)は、モジュールの読み込みが完了するまでブロックするため、慎重に使用しましょう。
  1. Promise.race()を使ったタイムアウト処理:
  • 長時間実行される可能性のある非同期処理には、タイムアウトを設定することを検討しましょう。

まとめ

TypeScriptにおけるasync/awaitは、非同期処理を同期的に書けるようにする強力な機能です。Promiseベースの非同期コードを読みやすく、理解しやすく、保守しやすくします。
この記事では、async/awaitの基本的な概念から実践的なパターンまでを初心者向けに解説しました。以下が主なポイントです:

  • JavaScriptは基本的にシングルスレッドだが、非同期処理によって応答性の高いアプリケーションを作ることができる
  • Promiseは非同期処理の結果を表現するオブジェクトで、async/awaitはそれをさらに扱いやすくする構文
  • asyncキーワードは関数がPromiseを返すことを示し、awaitキーワードはPromiseが解決されるまで実行を一時停止する
  • エラーハンドリングにはtry/catchブロックを使用する

非同期処理は最初は複雑に感じるかもしれませんが、async/awaitを使うことで、同期的なコードに近い感覚で書けるようになります。継続的な練習と実践を通じて、非同期プログラミングのスキルを向上させていきましょう。
何か不備などのご指摘があれば教えていただけると嬉しいです!

参考資料

Discussion