Menu

TypeScriptのasync/await: 型、エラー処理、並列実行

TypeScriptでの async と await の型付けを解説します。async 関数は Promise<T> を返し、await がそれを展開し、エラーは try/catch で捕まえます。トップレベルの await には ES モジュールが必要なこと、1つずつ await する場合と並列に await する場合の違い、forEach の落とし穴も扱います。

このページのコードはエディタで実行できます - 編集してすぐに結果を確認できます。

TypeScriptの async 関数は常に Promise を返します。本体が string を返すなら、型は Promise<string> です。その中では、await が Promise<T> を T に展開します。

実行時の動き(await が何を一時停止するか、イベントループ)はJavaScriptのもので、JavaScriptのasync/await で説明しています。TypeScriptが担当するのは、入る型と出る型です。

async 関数の戻り値の型

本体は普通の値を返しますが、async 関数の宣言された戻り値の型は Promise<...> でなければなりません。省略すればTypeScriptが推論します。

async function count(): number と書くとエラー TS1064(The return type of an async function or method must be the global Promise<T> type. Did you mean to write 'Promise<number>'?)です。ユーティリティ型 Awaited<T> は展開した型を返します。Awaited<ReturnType<typeof count>> は number です。

await を忘れる

await を忘れると、T ではなく Promise<T> を持つことになります。型が合わなくなるので、TypeScriptはたいていこれを捕まえます:

if (ok) のバグは現実のものです。Promise オブジェクトは常に truthy なので、アクセスを許可してしまいました。TypeScriptはこれを TS2801 として報告します。Promise を boolean 型の変数に代入したり、Promise にないプロパティを読んだりしても、コンパイルエラーになります。結果を無視する呼び出し(save(user);)は捕まえられません。それには typescript-eslint のルール no-floating-promises を使います。

try/catch によるエラー処理

reject された Promise を await すると例外が投げられるので、普通の try/catch が使えます。strict のもとでは catch の変数は unknown なので、.message を読む前に絞り込みます:

async 関数の中の throw は、呼び出し箇所で例外を投げるのではなく、その関数の Promise を reject します。独自のエラークラスや、例外を投げずに結果を返す方法など、ほかのパターンは エラー処理 のページにあります。

トップレベルの await

関数の外の await が使えるのは ES モジュールだけです。CommonJS としてコンパイルされるファイル(このページの例や、"type": "module" のない Node のプロジェクト)では拒否されます:

index.ts(2,14): error TS1309: The current file is a CommonJS module and cannot use 'await' at the top level.

このページのすべての例と同じように、コードを async な main 関数で囲んで呼び出しましょう。ES モジュールのプロジェクト(package.json に "type": "module" があり、module が node16 か nodenext、またはバンドラー向けの module: "esnext")では、トップレベルの await が使えます。

逐次実行と並列実行

await はそれぞれ、Promise を待ってから次の行を始めます。互いに独立した呼び出しなら、先にすべてを開始し、Promise.all でまとめて await しましょう:

逐次実行の前半では、b はまだ始まっていないので a より先に終わることはありません。並列実行の後半では d が先に終わり、全体の時間は合計ではなく、いちばん長い呼び出しとほぼ同じになります。それでも Promise.all は結果を入力の順序で、タプルとして型付けして返します。

forEach の落とし穴

forEach は async のコールバックが返す Promise を無視するので、処理を待つものは何もありません:

await を使った for...of は要素を1つずつ処理し、map と組み合わせた Promise.all は並列に実行してすべてを待ちます。forEach はそのどちらでもなく、TypeScriptも警告しません。void を返すと型付けされたコールバックは、Promise を返すコールバックも受け付けるからです。

for await による非同期の反復

for await...of は、非同期ジェネレーターのような非同期イテラブルをループし、各値を await します:

async function* pages(total: number): AsyncGenerator<string[]> {
    for (let page = 1; page <= total; page++) {
        await new Promise((r) => setTimeout(r, 10));
        yield [`item ${page}a`, `item ${page}b`];
    }
}

async function main() {
    for await (const batch of pages(3)) {
        console.log(batch.join(", ")); // batch: string[]
    }
}
main();

要素の型は、ジェネレーターの AsyncGenerator<T> の型注釈から、または省略した場合は推論から決まります。

よくある質問

TypeScriptの async 関数の戻り値の型は何ですか?

常に Promise です。number を返す async 関数の戻り値の型は Promise<number> で、何も返さない関数は Promise<void> です。async function f(): number と書くとエラー TS1064 になり、Promise<number> を使うよう提案されます。

TypeScriptでトップレベルの await を使うには?

トップレベルの await が使えるのは ES モジュールだけです。module を es2022、esnext、system、preserve、または Node が ESM として扱うファイルでの node16/node18/node20/nodenext にし、target を es2017 以上にする必要があります。CommonJS のファイルではエラー TS1309 です。どこでも使える解決策は async な main 関数です: async function main() { ... } main();。

TypeScriptで async/await のエラーを処理するには?

await を try/catch で囲みます。strict のもとでは捕捉した値は unknown 型なので、先に絞り込みます: if (e instanceof Error) console.log(e.message)。await も catch もされない reject は、未処理の reject になります。

TypeScriptで非同期の呼び出しを並列に実行するには?

先にすべての Promise を開始し、まとめて await します: const [a, b] = await Promise.all([loadA(), loadB()])。await loadA(); await loadB(); と書くと1つずつ順に実行されます。Promise.all は結果のタプルで各結果の型を保ちます。

forEach の中で async が動かないのはなぜですか?

forEach はコールバックを呼んで戻り値を無視するので、async のコールバックが返す Promise は await されません。ループはすぐに終わり、処理が終わる前にループのあとのコードが実行されます。1つずつ処理するなら await を使った for...of を、並列に処理するなら await Promise.all(items.map(async (x) => ...)) を使いましょう。

Coddy programming languages illustration

Coddyでコードを学ぼう

始める