TypeScriptの非同期関数の型定義とPromiseを使った関数型プログラミング入門

TypeScriptにおける非同期処理は、現代のウェブアプリケーション開発において重要な役割を果たしています。特に、サーバーからのデータ取得や外部APIとの通信といった、時間のかかる処理を効率的に行うためには、非同期関数とPromiseを効果的に利用することが必要です。この記事では、非同期処理を行う関数の型定義方法から、Promiseを活用した関数型プログラミングの応用例まで、段階的に学べるように解説します。非同期処理を理解し、TypeScriptの強力な型システムを活かして、バグの少ない堅牢なコードを書けるようになることを目指します。

目次

非同期関数とは


非同期関数とは、時間がかかる処理をブロックせずに他の処理を進めるために設計された関数です。非同期処理では、処理の完了を待つ必要がある場合でも、他のコードが実行を続けることができます。JavaScriptやTypeScriptでは、非同期関数の典型的な例として、データベースからのデータ取得やAPIとの通信が挙げられます。

同期処理と非同期処理の違い


同期処理では、一つの処理が完了するまで他の処理が実行されません。例えば、データベースにクエリを送ると、その応答が返ってくるまでプログラムは停止します。一方、非同期処理では、クエリが送られた後もプログラムは停止せず、他の処理が続けられます。応答が返ってきた時点で結果を受け取る仕組みです。

JavaScriptにおける非同期処理の重要性


JavaScriptはシングルスレッドで実行されるため、重い処理が実行されると、他のタスクがブロックされてしまう可能性があります。これにより、ユーザーインターフェースが応答しなくなるなどの問題が発生します。そのため、非同期処理を適切に活用することで、ユーザー体験の向上やパフォーマンスの最適化が可能になります。

Promiseの基本構造と使い方


Promiseは、JavaScriptおよびTypeScriptにおける非同期処理を扱うためのオブジェクトです。非同期処理が完了した時に、その結果を返す「約束」を表します。Promiseは、今後結果が返ってくることを約束し、その結果が得られたときに、次の処理を行うことができます。

Promiseの基本構造


Promiseは、3つの状態を持ちます。

  1. Pending(保留中): 非同期処理がまだ完了していない状態。
  2. Fulfilled(解決済み): 非同期処理が成功し、結果が得られた状態。
  3. Rejected(拒否): 非同期処理が失敗した状態。

Promiseを作成する際は、new Promiseコンストラクタを使用します。このコンストラクタは、resolverejectの2つの引数を受け取り、非同期処理の結果に応じてそれぞれ呼び出します。

const myPromise = new Promise((resolve, reject) => {
  const success = true;
  if (success) {
    resolve("成功しました!");
  } else {
    reject("エラーが発生しました");
  }
});

thenとcatchを使ったPromiseの処理


Promiseが解決(fulfilled)された場合はthen、失敗(rejected)した場合はcatchを使用して次の処理を記述します。

myPromise
  .then((result) => {
    console.log(result);  // "成功しました!"
  })
  .catch((error) => {
    console.log(error);  // "エラーが発生しました"
  });

thenメソッドは、Promiseが正常に完了した場合に実行され、catchメソッドはエラーが発生した際に呼び出されます。これにより、非同期処理の結果に応じた処理を柔軟に行うことが可能です。

Promiseの利用場面


Promiseは、APIからのデータ取得やファイル読み込みなど、時間のかかる非同期処理を行う場面で非常に有効です。thencatchを使うことで、エラー処理も含めて非同期処理を直感的に扱うことができ、コードの可読性を向上させます。

async/await構文の概要


async/awaitは、Promiseベースの非同期処理をより直感的に記述するための構文です。thencatchを使うPromiseチェーンと異なり、async/awaitを使うことで、非同期コードをあたかも同期処理のように書くことができ、コードの可読性が大幅に向上します。

async/awaitの基本的な使い方


async関数は、自動的にPromiseを返します。関数をasyncとして定義することで、その中でawaitを使うことができるようになります。awaitは、Promiseの完了を待ち、その結果を返します。以下に基本的な使い方を示します。

async function fetchData() {
  const response = await fetch('https://api.example.com/data');
  const data = await response.json();
  return data;
}

上記の例では、awaitによってfetch関数の結果を待ち、その後にデータを処理しています。awaitはPromiseが解決されるまで次の行をブロックし、結果を受け取って処理を続行します。

async/awaitの利点


async/await構文の主な利点は、Promiseチェーンを使うよりもシンプルで読みやすいコードが書ける点にあります。複雑な非同期処理でも、同期処理のように直線的に記述できるため、エラーハンドリングや複数の非同期操作を扱う際に非常に有効です。

従来のPromiseチェーンの例:

fetchData()
  .then(response => response.json())
  .then(data => console.log(data))
  .catch(error => console.error(error));

これがasync/awaitを使うと次のようになります。

async function displayData() {
  try {
    const response = await fetchData();
    console.log(response);
  } catch (error) {
    console.error(error);
  }
}

エラーハンドリング


async/await構文では、エラーハンドリングも簡潔に行えます。try...catchブロックを用いて、同期処理と同様にエラー処理を行うことができ、コードの可読性が向上します。

async function processData() {
  try {
    const data = await fetchData();
    console.log(data);
  } catch (error) {
    console.error('データの取得に失敗しました', error);
  }
}

このように、非同期処理のエラーハンドリングがより直感的に行えるため、async/awaitは複雑な非同期ロジックを扱う際に非常に役立ちます。

非同期関数の型定義方法


TypeScriptでは、非同期関数の型定義を行うことで、非同期処理の信頼性を高め、バグを減らすことができます。非同期関数は通常、Promiseを返すため、戻り値の型を適切に定義することが重要です。これにより、関数が返すデータの型を予測しやすくなり、他の開発者も安心してコードを使用できます。

Promiseの型定義


非同期関数が返すPromiseの型は、Promise<T>の形で定義します。ここでTは、Promiseが解決した際に返す値の型を表します。例えば、データを取得する非同期関数の場合、次のように型を定義します。

async function fetchData(): Promise<string> {
  return "データ取得完了";
}

この例では、fetchData関数が文字列型の値を持つPromiseを返すことをTypeScriptに伝えています。async関数は常にPromiseを返すため、戻り値の型としてPromise<T>を明示することが推奨されます。

非同期関数の引数の型定義


関数の引数にも型定義を行うことで、関数をより堅牢にできます。例えば、APIからユーザーデータを取得する非同期関数の引数にユーザーIDを渡す場合、次のように型を定義できます。

async function getUserData(userId: number): Promise<User> {
  const response = await fetch(`https://api.example.com/users/${userId}`);
  const data: User = await response.json();
  return data;
}

この場合、userIdnumber型であり、関数の戻り値はUser型のデータを持つPromiseです。このように型を定義することで、意図しない値の渡し方や戻り値に関するバグを防ぐことができます。

ジェネリクスを用いた非同期関数の型定義


TypeScriptでは、ジェネリクスを使用して、汎用的な非同期関数を作成することができます。ジェネリクスを使うことで、返り値の型を関数の使用時に指定できるため、再利用性の高い関数を定義できます。

async function fetchData<T>(url: string): Promise<T> {
  const response = await fetch(url);
  const data: T = await response.json();
  return data;
}

この関数は、任意の型Tを持つデータを返すPromiseを返します。呼び出し時に、取得したいデータの型を指定することで、型の安全性を保ちながら柔軟に使用できます。

const userData = await fetchData<User>('https://api.example.com/user/1');
const postData = await fetchData<Post>('https://api.example.com/post/1');

型定義のメリット


非同期関数に型定義を行うことにより、以下のメリットがあります:

  1. 安全性の向上: 間違ったデータ型を扱うミスを防ぎ、バグを減らします。
  2. 可読性の向上: 関数がどのようなデータを扱うのかが明確になり、コードが理解しやすくなります。
  3. 開発支援: エディタの補完機能が正確に働くため、開発効率が向上します。

このように、非同期関数の型定義は、TypeScriptの型システムを最大限に活用するために不可欠なステップであり、プロジェクトの品質向上に大きく寄与します。

Promiseを使った関数型プログラミングの基礎


関数型プログラミング(Functional Programming, FP)は、関数を第一級オブジェクトとして扱い、副作用を最小限に抑えるプログラミング手法です。JavaScriptやTypeScriptでは、非同期処理においてもPromiseを活用してこの手法を適用できます。Promiseを使った関数型プログラミングは、コードの再利用性を高め、非同期処理をよりシンプルに表現するために有効です。

関数型プログラミングの基本概念


関数型プログラミングの基本概念には以下の要素があります。

  1. 関数の第一級オブジェクト化: 関数を引数として渡したり、他の関数の結果として返すことが可能。
  2. 純粋関数: 同じ入力に対して常に同じ結果を返し、副作用を持たない関数。
  3. 不変性: データを変更せず、新しいデータを返す形で操作すること。

Promiseを活用した関数型プログラミングでは、これらの原則を活かして、非同期処理を効率的かつ読みやすいコードにすることができます。

Promiseチェーンを用いた非同期関数の組み合わせ


関数型プログラミングでは、関数の合成を使って、複数の非同期処理を順番に実行することがよく行われます。Promiseは、thenメソッドを使うことで、チェーンのように次々と関数を呼び出すことができ、非同期処理を組み合わせるのに適しています。

function fetchUser(): Promise<User> {
  return fetch('https://api.example.com/user')
    .then(response => response.json());
}

function fetchPosts(userId: number): Promise<Post[]> {
  return fetch(`https://api.example.com/posts?userId=${userId}`)
    .then(response => response.json());
}

fetchUser()
  .then(user => fetchPosts(user.id))
  .then(posts => console.log(posts))
  .catch(error => console.error(error));

この例では、fetchUserfetchPostsという2つの非同期関数を順番に実行し、その結果をもとに次の関数を呼び出しています。各関数はPromiseを返し、非同期処理を自然な形で組み合わせています。

高階関数を使ったPromiseの活用


関数型プログラミングでは、高階関数(関数を引数に取ったり返したりする関数)が重要な役割を果たします。Promiseを使用する場合でも、高階関数を利用して汎用的な非同期処理を実装することが可能です。

例えば、APIへのリクエストを一般化した関数を作り、それを他の処理に適用できます。

function withErrorHandling<T>(promise: Promise<T>): Promise<T> {
  return promise.catch(error => {
    console.error('エラー:', error);
    throw error;  // 再度エラーを投げて処理を続けさせる
  });
}

const userPromise = withErrorHandling(fetchUser());
const postsPromise = withErrorHandling(fetchPosts(1));

このように高階関数を使ってPromiseの共通処理(エラーハンドリングなど)を抽象化することで、コードの再利用性を高め、冗長なコードを減らすことができます。

関数の合成によるPromise処理の効率化


関数型プログラミングでは、関数の合成(関数同士を組み合わせて新しい関数を作ること)を使って、複雑な処理をシンプルに表現します。非同期処理でも関数を合成することで、Promiseを効率的に活用できます。

以下の例では、複数の非同期処理を組み合わせて、データの取得と処理を行います。

function fetchDataAndProcess(url: string): Promise<void> {
  return fetch(url)
    .then(response => response.json())
    .then(data => process(data))
    .catch(error => console.error('データ処理中にエラーが発生しました', error));
}

function process(data: any): void {
  console.log('データを処理しています', data);
}

この関数では、データの取得と処理を合成し、一連のPromiseチェーンとして実行しています。関数型プログラミングの考え方を導入することで、複数の非同期処理を組み合わせた柔軟な設計が可能です。

Promiseを使った関数型プログラミングは、複雑な非同期処理をシンプルかつ可読性の高いコードで実装でき、バグの発生を防ぎやすくする点で非常に有効です。

高階関数とPromise


高階関数は、引数として関数を受け取ったり、結果として関数を返す関数です。JavaScriptやTypeScriptでよく使われるパターンであり、特に非同期処理をPromiseで扱う際にも強力なツールとなります。Promiseを利用して非同期処理を行う場合、高階関数を使用することで、コードの再利用性を高め、非同期処理を柔軟に管理できるようになります。

高階関数の基本的な役割


高階関数は、関数を引数として受け取ったり、他の関数を返したりすることで、汎用的な処理を実現します。非同期処理で高階関数を利用することで、Promiseチェーンを効率的に扱うことができます。例えば、複数のAPIリクエストを順番に実行する関数を実装したい場合、高階関数を使うことで、動的に処理の流れを組み替えることが可能です。

function executeWithLogging<T>(promiseFunction: () => Promise<T>): Promise<T> {
  console.log("非同期処理を開始します");
  return promiseFunction()
    .then(result => {
      console.log("非同期処理が成功しました");
      return result;
    })
    .catch(error => {
      console.error("非同期処理中にエラーが発生しました", error);
      throw error;
    });
}

この関数は、任意の非同期処理にログ出力機能を追加するための高階関数です。promiseFunctionとして渡される非同期処理を実行し、その前後でログを出力します。高階関数を使用することで、コードの構造をより柔軟に管理でき、共通処理の再利用が簡単になります。

高階関数を用いた非同期処理の組み合わせ


非同期処理では、複数のPromiseを組み合わせて処理を実行することが多々あります。高階関数を使用することで、これらのPromiseを柔軟に扱うことができます。次の例では、高階関数を用いて、ユーザーデータの取得とそのデータに基づく投稿データの取得を行っています。

function fetchData<T>(url: string): Promise<T> {
  return fetch(url).then(response => response.json());
}

function fetchUserAndPosts(userId: number): Promise<{ user: User, posts: Post[] }> {
  return fetchData<User>(`https://api.example.com/users/${userId}`)
    .then(user => {
      return fetchData<Post[]>(`https://api.example.com/posts?userId=${user.id}`)
        .then(posts => ({ user, posts }));
    });
}

この例では、fetchDataという汎用的なデータ取得関数を用い、ユーザーデータと投稿データを取得しています。高階関数を用いることで、Promiseの流れをシンプルにし、処理の組み合わせが柔軟に行えるようになっています。

カリー化とPromise


カリー化(Currying)は、複数の引数を取る関数を、1つの引数だけを取る関数の連鎖に変換する技法です。非同期処理とPromiseを組み合わせたカリー化は、複雑な処理を段階的に分けて実行でき、特定の条件に応じた処理を柔軟に実行できます。

function fetchUserData(userId: number) {
  return (url: string): Promise<User> => {
    return fetch(`${url}/users/${userId}`).then(response => response.json());
  };
}

const fetchSpecificUser = fetchUserData(1);
fetchSpecificUser("https://api.example.com")
  .then(user => console.log(user));

ここでは、fetchUserDataがカリー化された関数として実装されており、userIdurlを別々の段階で渡すことができます。このアプローチにより、非同期処理のフローを柔軟に変更できるため、コードの再利用性が大幅に向上します。

非同期処理における高階関数の利点


高階関数をPromiseで使用する主な利点は以下の通りです:

  1. 再利用性の向上: 一般的なロジックやエラーハンドリングを抽象化し、さまざまな場面で繰り返し使用できます。
  2. コードの簡潔化: 複雑な非同期処理を高階関数でまとめることで、読みやすく、保守しやすいコードになります。
  3. 柔軟な処理フロー: 高階関数を使用することで、Promiseチェーンを自由に組み合わせ、異なる条件に応じた処理フローを動的に作成可能です。

このように、高階関数とPromiseを組み合わせることで、複雑な非同期処理を効率的かつ柔軟に管理することが可能です。高階関数は、非同期処理の構造を整理し、コードの再利用性と可読性を大幅に向上させるための強力なツールです。

エラーハンドリングとPromiseチェーン


非同期処理においてエラーハンドリングは重要な役割を果たします。特にPromiseチェーンでは、エラーがどの段階で発生しても適切に処理しなければ、プログラムの動作に予期せぬ不具合が発生する可能性があります。Promiseを使った非同期処理では、エラーハンドリングが容易で、効率的に問題を検出し処理することが可能です。

Promiseチェーンにおけるエラーの伝播


Promiseチェーンでは、チェーン内のいずれかのPromiseが失敗した場合、そのエラーは次のthenではなく、catchまで伝播されます。これにより、チェーン全体のどの段階でエラーが発生しても、一括してエラーを処理することが可能です。

fetchData()
  .then(data => processData(data))
  .then(result => saveData(result))
  .catch(error => {
    console.error("エラーが発生しました:", error);
  });

この例では、fetchDataprocessDatasaveDataのいずれかのPromiseが失敗した場合、エラーはcatchに伝えられます。こうすることで、Promiseチェーンのどの段階でも発生したエラーを一箇所で一貫して処理できます。

エラーハンドリングのベストプラクティス


Promiseを使ったエラーハンドリングにはいくつかのベストプラクティスがあります。以下はその代表的な方法です。

1. 各段階でエラーをキャッチする


catchをPromiseチェーンの最後にまとめるのではなく、各段階で個別にエラー処理を行う方法です。これにより、どの処理でエラーが発生したのかを明確にすることができます。

fetchData()
  .then(data => processData(data))
  .catch(error => {
    console.error("データ処理中にエラーが発生しました:", error);
  })
  .then(result => saveData(result))
  .catch(error => {
    console.error("データ保存中にエラーが発生しました:", error);
  });

この場合、どの段階でエラーが発生したかをより詳細に把握できます。各処理ごとに異なるエラーハンドリングを適用したい場合に有効です。

2. finallyを使って終了処理を実行


Promiseにはfinallyというメソッドがあり、成功・失敗に関わらず、Promiseが解決された後に必ず実行される処理を記述できます。リソースの開放や後処理を行うのに便利です。

fetchData()
  .then(data => processData(data))
  .catch(error => {
    console.error("エラーが発生しました:", error);
  })
  .finally(() => {
    console.log("非同期処理が完了しました");
  });

finallyは、エラーが発生しても発生しなくても実行されるため、後処理を一箇所でまとめて行うことができます。

Promise.allを使ったエラーハンドリング


複数のPromiseを並行して実行する際には、Promise.allを利用することで全てのPromiseが解決されるのを待つことができます。しかし、Promise.allは1つでもエラーが発生すると即座に失敗するため、全てのPromiseの結果を待たずにエラーが伝播されます。

Promise.all([fetchUser(), fetchPosts()])
  .then(([user, posts]) => {
    console.log('ユーザー:', user);
    console.log('投稿:', posts);
  })
  .catch(error => {
    console.error("どれかの非同期処理に失敗しました:", error);
  });

この例では、fetchUserfetchPostsのどちらかが失敗すると、即座にcatchが呼ばれ、どのPromiseが失敗したかを確認できます。

Promiseチェーン内での再スロー


エラーが発生した後にそれを補足して処理したうえで、再度エラーをスローすることも可能です。これにより、エラーがキャッチされた後でも、エラーをチェーン内で伝播させ続けることができます。

fetchData()
  .then(data => processData(data))
  .catch(error => {
    console.error("データ処理エラー:", error);
    throw new Error("データ処理の失敗が再スローされました");
  })
  .then(result => saveData(result))
  .catch(error => {
    console.error("全体エラーハンドリング:", error);
  });

このように、最初のcatchブロックでエラーを処理し、その後再びエラーを投げて別の処理に伝えることができます。

エラーハンドリングの重要性


Promiseチェーンにおけるエラーハンドリングは、アプリケーションの安定性を維持するために不可欠です。非同期処理は、特にネットワーク通信や外部APIの利用において失敗のリスクが高いため、しっかりとしたエラーハンドリングを実装することで、予期しない問題に柔軟に対処できるコードを作成できます。

このように、Promiseチェーンを使う際には、エラーハンドリングを適切に設計し、ユーザーにとって予測可能かつ安定した動作を実現することが重要です。

Promise.allとPromise.raceの活用


Promiseを使った非同期処理では、複数の非同期処理を同時に行う場面が頻繁にあります。TypeScriptでは、これらの非同期処理を効率的に管理するためにPromise.allPromise.raceといったユーティリティ関数が用意されています。これらを活用することで、複数の非同期処理の結果をまとめて取得したり、最も早く完了した処理の結果を取得したりすることが可能です。

Promise.allの基本


Promise.allは、複数のPromiseを並行して実行し、それらすべてが解決されたときに結果をまとめて返すメソッドです。どれか1つでも失敗した場合、全体が失敗として扱われ、エラーハンドリングが呼び出されます。これは、全ての非同期処理の結果を待ってから処理を進めたい場合に非常に有用です。

const fetchUser = fetch('https://api.example.com/user').then(res => res.json());
const fetchPosts = fetch('https://api.example.com/posts').then(res => res.json());

Promise.all([fetchUser, fetchPosts])
  .then(([user, posts]) => {
    console.log('ユーザー:', user);
    console.log('投稿:', posts);
  })
  .catch(error => {
    console.error('エラーが発生しました:', error);
  });

この例では、ユーザーデータと投稿データの両方を同時に取得しています。Promise.allは、全てのPromiseが解決されるまで待ち、両方の結果を一度に受け取ることができます。

Promise.allのエラーハンドリング


Promise.allは、一つでもPromiseが失敗すると、即座にcatchブロックが呼ばれます。すべてのPromiseが成功する必要がある場合には便利ですが、部分的に失敗しても処理を進めたい場合には工夫が必要です。

Promise.all([
  fetch('https://api.example.com/user').then(res => res.json()).catch(error => null),
  fetch('https://api.example.com/posts').then(res => res.json()).catch(error => [])
])
  .then(([user, posts]) => {
    console.log('ユーザー:', user);
    console.log('投稿:', posts);
  });

ここでは、エラーが発生した場合でも、それぞれのPromiseがnullや空の配列を返すようにしており、他のPromiseが成功していれば処理が進むようになっています。

Promise.raceの基本


Promise.raceは、複数のPromiseのうち最も早く完了したものを返すメソッドです。最初に解決されたPromiseの結果が即座に返され、他のPromiseはその後無視されます。Promise.raceは、タイムアウト処理や最も早く応答を得たサーバーを使用したい場合などに活用されます。

const fastPromise = new Promise((resolve) => setTimeout(resolve, 100, '速い結果'));
const slowPromise = new Promise((resolve) => setTimeout(resolve, 200, '遅い結果'));

Promise.race([fastPromise, slowPromise])
  .then(result => {
    console.log(result);  // '速い結果' が出力される
  });

この例では、Promise.raceに渡された2つのPromiseのうち、最も早く解決されたfastPromiseの結果が返されます。Promise.raceは、非同期処理の完了を競わせる場合に便利です。

Promise.raceを使ったタイムアウト処理


Promise.raceを使ってタイムアウト処理を実装することができます。たとえば、APIからのレスポンスが遅すぎる場合に、タイムアウトエラーを発生させることが可能です。

const fetchData = fetch('https://api.example.com/data');
const timeout = new Promise((_, reject) => setTimeout(() => reject('タイムアウトです'), 5000));

Promise.race([fetchData, timeout])
  .then(response => console.log('データ取得成功:', response))
  .catch(error => console.error('エラー:', error));

この例では、fetchDataの処理が5秒以内に完了すればその結果が返されますが、5秒を超えるとタイムアウトエラーが発生します。これにより、レスポンスが遅すぎる場合に対しても柔軟に対応できます。

Promise.allSettledの利用


Promise.allは一つのPromiseが失敗すると全体が失敗と見なされますが、Promise.allSettledはすべてのPromiseが完了するのを待ち、それぞれのPromiseが成功したか失敗したかにかかわらず、すべての結果を配列で返します。部分的な成功・失敗を扱いたい場合に有効です。

Promise.allSettled([
  fetch('https://api.example.com/user').then(res => res.json()),
  fetch('https://api.example.com/posts').then(res => res.json())
])
  .then(results => {
    results.forEach(result => {
      if (result.status === 'fulfilled') {
        console.log('成功:', result.value);
      } else {
        console.error('失敗:', result.reason);
      }
    });
  });

この例では、各Promiseの結果がfulfilledrejectedかに応じて異なる処理を行っています。すべての結果が得られるため、個別にエラー処理をしたい場合に便利です。

まとめ


Promise.allPromise.raceは、複数の非同期処理を効率的に管理するための強力なツールです。Promise.allはすべての非同期処理を一度に解決し、Promise.raceは最速の結果を取得するために役立ちます。用途に応じてこれらのメソッドを活用することで、非同期処理を効率的に管理し、よりパフォーマンスの高いアプリケーションを作成できます。

実践例:APIリクエストと非同期関数


非同期処理を活用したAPIリクエストは、TypeScriptを使ったアプリケーション開発で非常に一般的です。ここでは、非同期関数とPromiseを使って、APIリクエストを効率的に行う方法を具体的な例で紹介します。この実践例を通じて、非同期処理の基本的な流れを理解し、実際の開発に応用できる知識を習得しましょう。

APIリクエストの基本的な流れ


まず、非同期関数を使用してAPIリクエストを行い、データを取得する基本的な流れを示します。この例では、ユーザーデータとその投稿データを取得し、それを表示する処理を実装します。

async function fetchUserData(userId: number): Promise<User> {
  const response = await fetch(`https://api.example.com/users/${userId}`);
  if (!response.ok) {
    throw new Error('ユーザーデータの取得に失敗しました');
  }
  return response.json();
}

async function fetchUserPosts(userId: number): Promise<Post[]> {
  const response = await fetch(`https://api.example.com/posts?userId=${userId}`);
  if (!response.ok) {
    throw new Error('投稿データの取得に失敗しました');
  }
  return response.json();
}

async function displayUserDataAndPosts(userId: number) {
  try {
    const user = await fetchUserData(userId);
    const posts = await fetchUserPosts(userId);
    console.log('ユーザー情報:', user);
    console.log('投稿:', posts);
  } catch (error) {
    console.error('エラー:', error);
  }
}

displayUserDataAndPosts(1);

このコードでは、非同期関数fetchUserDatafetchUserPostsを使ってAPIリクエストを行い、それぞれのデータを取得しています。displayUserDataAndPosts関数内でこれらの関数をawaitし、ユーザーデータと投稿データを取得して表示しています。エラーハンドリングにはtry...catchを使用して、失敗時の処理も明確にしています。

並列リクエストの実装


上記の例では、ユーザーデータと投稿データを順番に取得していましたが、これらのデータが独立している場合、並列でリクエストを送る方が効率的です。Promise.allを使うことで、複数の非同期リクエストを並列に処理し、パフォーマンスを向上させることができます。

async function displayUserDataAndPostsInParallel(userId: number) {
  try {
    const [user, posts] = await Promise.all([
      fetchUserData(userId),
      fetchUserPosts(userId)
    ]);
    console.log('ユーザー情報:', user);
    console.log('投稿:', posts);
  } catch (error) {
    console.error('エラー:', error);
  }
}

displayUserDataAndPostsInParallel(1);

この例では、Promise.allを使ってユーザーデータと投稿データを同時に取得しています。これにより、待ち時間が短縮され、より効率的にリクエストを処理できます。

タイムアウトの設定


APIリクエストが長時間かかる場合には、タイムアウトを設定して、一定時間内に応答がなければエラーを発生させるようにすることが重要です。Promise.raceを使って、APIリクエストとタイムアウト処理を競合させることで、タイムアウトを実装できます。

async function fetchWithTimeout(url: string, timeout: number): Promise<Response> {
  const timeoutPromise = new Promise<never>((_, reject) => 
    setTimeout(() => reject(new Error('タイムアウトしました')), timeout)
  );
  const fetchPromise = fetch(url);
  return Promise.race([fetchPromise, timeoutPromise]);
}

async function fetchUserDataWithTimeout(userId: number): Promise<User> {
  const response = await fetchWithTimeout(`https://api.example.com/users/${userId}`, 5000);
  if (!response.ok) {
    throw new Error('ユーザーデータの取得に失敗しました');
  }
  return response.json();
}

fetchUserDataWithTimeout(1)
  .then(user => console.log('ユーザー情報:', user))
  .catch(error => console.error('エラー:', error));

この例では、fetchWithTimeout関数を使って、指定したタイムアウト時間内にAPIリクエストが完了しなかった場合にエラーを発生させています。Promise.raceを利用することで、タイムアウトとリクエストのどちらが早く完了するかを競わせています。

エラーハンドリングとリトライ処理


APIリクエストは時折失敗することがあります。そのため、一定回数リトライを試みる処理を実装すると、ユーザーにより安定した体験を提供できます。以下はリトライ処理を含む例です。

async function fetchWithRetry(url: string, retries: number = 3): Promise<Response> {
  for (let attempt = 1; attempt <= retries; attempt++) {
    try {
      const response = await fetch(url);
      if (!response.ok) {
        throw new Error('リクエスト失敗');
      }
      return response;
    } catch (error) {
      if (attempt === retries) {
        throw new Error(`全てのリトライに失敗しました: ${error.message}`);
      }
      console.log(`リトライ中 (${attempt}/${retries})...`);
    }
  }
  throw new Error('リトライエラー');
}

async function fetchUserDataWithRetry(userId: number): Promise<User> {
  const response = await fetchWithRetry(`https://api.example.com/users/${userId}`);
  return response.json();
}

fetchUserDataWithRetry(1)
  .then(user => console.log('ユーザー情報:', user))
  .catch(error => console.error('エラー:', error));

この例では、fetchWithRetry関数で最大3回リトライを行い、それでも失敗した場合はエラーをスローします。これにより、通信の一時的な問題が発生しても、処理を継続できる柔軟な実装が可能になります。

まとめ


非同期関数とPromiseを使ったAPIリクエストの実装では、効率的なリクエスト管理とエラーハンドリングが重要です。並列処理やタイムアウト、リトライ機能を適切に活用することで、安定したパフォーマンスを実現し、ユーザーに快適な体験を提供することができます。これらの技術を活用して、より堅牢で効率的なアプリケーションを開発していきましょう。

TypeScriptとPromiseベースのユニットテスト


非同期処理を伴うコードの品質を保証するためには、ユニットテストが欠かせません。TypeScriptで非同期関数をテストする際、Promiseベースの処理を適切に検証する方法を理解することが重要です。非同期処理のユニットテストには、async/awaitを使用してPromiseの結果を待つ方法や、mockを使ったAPIリクエストのシミュレーションなどが含まれます。

非同期関数の基本的なテスト


まず、非同期処理を行う関数をテストするために、async/awaitを用いた基本的なテスト例を見てみましょう。この例では、APIからデータを取得する非同期関数をテストします。

// 非同期関数の例
async function fetchData(url: string): Promise<any> {
  const response = await fetch(url);
  if (!response.ok) {
    throw new Error('データの取得に失敗しました');
  }
  return response.json();
}

// Jestなどのテストライブラリを用いたテスト
test('fetchDataが成功した場合、データを返す', async () => {
  const mockResponse = { name: 'John Doe' };
  global.fetch = jest.fn().mockResolvedValue({
    ok: true,
    json: async () => mockResponse
  });

  const data = await fetchData('https://api.example.com/data');
  expect(data).toEqual(mockResponse);
});

test('fetchDataが失敗した場合、エラーをスローする', async () => {
  global.fetch = jest.fn().mockResolvedValue({
    ok: false
  });

  await expect(fetchData('https://api.example.com/data')).rejects.toThrow('データの取得に失敗しました');
});

この例では、jest.fn()を使ってfetch関数をモックし、APIリクエストをシミュレートしています。mockResolvedValueを使ってPromiseが解決される場合の挙動を設定し、実際に外部APIにアクセスせずにテストを行っています。非同期関数のテストでは、実際にAPIに接続する必要がないため、効率的なテストが可能です。

非同期処理の失敗をテストする


非同期処理が失敗した場合のエラーハンドリングをテストすることも重要です。Promiseが拒否された(reject)場合に、エラーが正しく処理されるかどうかをテストする方法を見てみましょう。

test('fetchDataが失敗した場合、エラーハンドリングが正しく行われる', async () => {
  global.fetch = jest.fn().mockRejectedValue(new Error('ネットワークエラー'));

  await expect(fetchData('https://api.example.com/data')).rejects.toThrow('ネットワークエラー');
});

このテストでは、mockRejectedValueを使用してPromiseが拒否されたシナリオをシミュレートしています。rejects.toThrowを使って、非同期関数が適切にエラーをスローすることを検証します。

Promise.allをテストする


複数のPromiseをPromise.allで処理する非同期関数をテストする際には、それぞれのPromiseが期待通りに解決されるか、あるいはエラーが適切に処理されるかを検証します。

async function fetchAllData(urls: string[]): Promise<any[]> {
  const promises = urls.map(url => fetchData(url));
  return Promise.all(promises);
}

test('fetchAllDataがすべてのデータを返す', async () => {
  const mockResponse = { name: 'John Doe' };
  global.fetch = jest.fn().mockResolvedValue({
    ok: true,
    json: async () => mockResponse
  });

  const urls = ['https://api.example.com/data1', 'https://api.example.com/data2'];
  const data = await fetchAllData(urls);
  expect(data).toEqual([mockResponse, mockResponse]);
});

test('fetchAllDataで1つのリクエストが失敗するとエラーをスローする', async () => {
  global.fetch = jest.fn()
    .mockResolvedValueOnce({
      ok: true,
      json: async () => ({ name: 'John Doe' })
    })
    .mockRejectedValueOnce(new Error('リクエスト失敗'));

  const urls = ['https://api.example.com/data1', 'https://api.example.com/data2'];
  await expect(fetchAllData(urls)).rejects.toThrow('リクエスト失敗');
});

この例では、複数のAPIリクエストが並列で実行されるfetchAllData関数のテストを行っています。1つのリクエストが失敗した場合に、Promiseが正しく拒否されるかを検証しています。

タイムアウト処理のテスト


タイムアウト処理を含む非同期関数のテストも重要です。Promise.raceなどを使ったタイムアウト処理のテストでは、タイムアウトが適切に発生するか、またはデータが正常に取得されるかを確認します。

test('fetchWithTimeoutがタイムアウトを処理する', async () => {
  global.fetch = jest.fn().mockResolvedValue(new Promise(resolve => setTimeout(() => resolve({
    ok: true,
    json: async () => ({ name: 'John Doe' })
  }), 10000)));

  await expect(fetchWithTimeout('https://api.example.com/data', 5000))
    .rejects.toThrow('タイムアウトしました');
});

このテストでは、タイムアウトをシミュレートするために、fetchが通常よりも長くかかるように設定しています。結果として、タイムアウトが発生し、適切にエラーハンドリングが行われるかを検証しています。

モックライブラリを使った外部APIのシミュレーション


jestなどのモックライブラリを使って、外部APIのリクエストやレスポンスをシミュレートすることで、現実のAPIに依存しないテストを行うことが可能です。これにより、テストは高速かつ安定的に実行され、APIの状態に左右されずに信頼性の高いテストが実現します。

まとめ


Promiseを使った非同期処理のユニットテストは、アプリケーションの品質を保証するために不可欠です。TypeScriptの強力な型システムとテストフレームワークを活用することで、非同期関数の挙動を正確に検証できます。エラーハンドリングやタイムアウト処理など、様々なケースを網羅したテストを実施することで、安定した非同期処理を実現しましょう。

まとめ


本記事では、TypeScriptにおける非同期関数の型定義とPromiseを使った関数型プログラミングの応用について詳しく解説しました。非同期関数の基本概念からPromiseチェーン、async/awaitの利用、複数のPromiseを効率的に扱うPromise.allPromise.race、さらにユニットテストによる品質保証まで、非同期処理に関する重要な要素をカバーしました。これらの技術を適切に活用することで、堅牢かつ効率的な非同期処理を実現し、開発の生産性を向上させることができます。

コメント

コメントする

目次