TypeScript

TypeScriptのPromiseとasync/await:戻り値の型・並行処理・エラー処理を学ぶ

この記事でわかること

TypeScriptのPromiseとasync/awaitを実践解説。順次処理、Promise.allとallSettled、unknownのエラー処理、非同期forEachの注意点を学びます。

Promise<T> は、後で成功したときに T 型の値を受け取る処理を表します。async 関数は Promise を返し、await は成功した値を受け取るか、失敗を例外として伝えます。型引数の T は成功する値の型であり、失敗する理由の型ではありません。

関数・オブジェクト型の基礎を前提に、順次処理、並行処理、失敗の扱いを実行できる例で確認します。掲載コードはTypeScript 4.9.4の strict で検証しています。ここで使う記事取得関数は学習用で、実際のHTTP通信や通信速度の再現は行いません。

Promise の成功と失敗を扱う

ファイル: src/articles.ts

export type Article = { id: number; title: string };

export async function loadArticle(id: number): Promise<Article> {
  if (!Number.isInteger(id) || id < 1) {
    throw new Error("記事IDは1以上の整数にしてください");
  }
  return { id, title: `記事${id}` };
}

export function errorMessage(error: unknown): string {
  return error instanceof Error ? error.message : "不明なエラー";
}

Article は記事そのもの、Promise<Article> は記事で成功する処理です。loadArticle(1) の戻り値から直接 title を読まず、await で記事を受け取ってから読みます。async 関数内で投げた例外は、返される Promise の失敗になります。async functionの仕様

失敗する理由は文字列などでも構わないため、必ず Error オブジェクトとは限りません。errorMessage()unknown で受け取り、instanceof Error で判定してから message を読みます。strict では、明示的に設定を上書きしない限り、catch の変数も unknown として扱われます。

順次処理・並行処理・全件の結果を比較する

ファイル: src/main.ts

import { errorMessage, loadArticle } from "./articles.js";

async function main(): Promise<void> {
  try {
    const article = await loadArticle(1);
    console.log(article.title); // 記事1
    await loadArticle(0);
  } catch (error: unknown) {
    console.log(errorMessage(error)); // 記事IDは1以上の整数にしてください
  }

  // 前の処理の完了を待ってから、次の処理を開始する。
  for (const id of [1, 2]) {
    const article = await loadArticle(id);
    console.log(`順次: ${article.title}`);
  }

  // 各関数を呼んで処理を開始し、まとめて完了を待つ。
  const [first, second] = await Promise.all([
    loadArticle(1),
    loadArticle(2),
  ]);
  console.log(`並行: ${first.title}, ${second.title}`);

  // 失敗があっても全件が確定するまで待ち、個別に結果を扱う。
  const results = await Promise.allSettled([
    loadArticle(1),
    loadArticle(0),
  ]);
  for (const result of results) {
    if (result.status === "fulfilled") {
      console.log(`成功: ${result.value.title}`);
    } else {
      console.log(`失敗: ${errorMessage(result.reason)}`);
    }
  }
}

main().catch((error: unknown) => {
  console.error(errorMessage(error));
  process.exitCode = 1;
});

for...of の中で await すると、一つの取得が完了してから次の取得を開始します。前の結果に次の処理が依存するときや、順番が重要なときに使います。

Promise.all() では、渡す配列を作るときにそれぞれの loadArticle() が呼ばれます。関数名だけを配列に並べても、Promise.all() が代わりに呼び出してくれるわけではありません。返る配列の順序は、完了した順ではなく入力の順です。Promise.allの説明

Promise.allSettled() は、成功・失敗を含む全件の結果が確定するまで待ちます。status に応じて valuereason を使い分けます。Promise.allSettledの説明

Promise.all() の失敗は他の処理を停止しない

Promise.all() に渡した処理の一つが失敗すると、まとめた Promise は失敗します。ただし、すでに開始した他の通信や処理が自動でキャンセルされるわけではありません。中断するには、対象のAPIが対応する中断方法を別に組み込む必要があります。

大量のIDを map() してすべての通信を一度に開始すると、接続数や相手側の制限を超える場合があります。その場合は同時に動く処理の数を制限します。Promise.all() 自体には、開始数を制限する機能はありません。

forEach() は非同期コールバックを待たない

ids.forEach(async id => { await loadArticle(id); }) の外側は、各コールバックの完了を待ちません。await ids.forEach(...) としても、forEach()undefined を返すため解決しません。forEachの非同期処理に関する説明

順次実行なら上の for...of、まとめて待つなら await Promise.all(ids.map(id => loadArticle(id))) のように、実際に待つ Promise を明示します。開始した処理の失敗を扱う場所も決めてください。

実行方法

空の作業用フォルダで npm init -ynpm install --save-dev typescript @types/node を実行します。Node.jsと型定義のバージョンを対応させ、上の2ファイルを src に保存します。

ファイル: tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "noEmitOnError": true,
    "rootDir": "src",
    "outDir": "dist",
    "lib": ["ES2022"],
    "types": ["node"]
  },
  "include": ["src/**/*.ts"]
}
npx tsc -p tsconfig.json
node dist/main.js

練習として Promise.all() の片方のIDを 0 に変えてください。続く処理へ進まず、最後の main().catch() が失敗を受け取ります。全件の結果を確認する例へ戻すときは、元のIDに戻してから再ビルドします。

実際のHTTP通信に置き換えるとき

型注釈や as Article は、外部から返るJSONを検証しません。HTTPの状態と受け取ったデータの形を確かめてから Article として扱います。検証の組み立て方は ジェネリクスとJSONの検証 でも解説しています。

よくある質問

await はアプリ全体を停止しますか?

待っている非同期関数の続きが中断されます。他の実行可能な処理は進められますが、await の前後にある長い同期処理は、そのスレッドを占有します。async を付けるだけで計算が別スレッドへ移るわけではありません。

戻り値のデータがない場合はどう書きますか?

Promise<void> と表せます。データを返さなくても失敗することはあるため、呼び出し側で awaitcatch() を使って完了と失敗を扱います。

非同期処理を設計するときの判断

依存する処理だけを順番に待つ

前の結果を次が必要とするなら順次処理にします。独立した処理なら並行実行を検討できますが、開始する件数、通信先の制限、失敗時に必要な結果を先に決めます。

画面の状態も型で表す

読み込み中、成功、失敗を別の状態として定義すると、結果がないのに表示する間違いを見つけやすくなります。非同期の戻り値の型と、利用側の状態設計を組み合わせて確認してください。

関連記事・参考資料

TypeScriptとは・学習順・目的別の記事一覧へ戻る

y.
WRITTEN BY

y_ymo10

SEの部屋で、JavaScript・TypeScript・React.js・Next.jsの開発ノートを公開しています。

ほかの開発ノートを読む →