TypeScript

非同期関数の書き方|async宣言・await式・TypeScriptの型定義とdelay関数の実装

非同期関数でつまずく場所は、だいたい3つに絞られます。async を付けた関数が何を返すのか、await をどこに書けるのか、TypeScriptでその戻り値をどう型で表すのか。ここではJavaScriptとTypeScriptの実際のコードで、この3点と、待機処理でよく必要になるdelay関数の実装までを扱います。非同期処理そのものの考え方(同期処理との違い、イベントループ、言語ごとの設計思想)は非同期処理とは?同期処理との違いから実装方式まで実装者目線で解説に、エラーの捕まえ方と並列実行の制御はJavaScriptの非同期エラーハンドリングと並列実行|try/catch・Promise.all・allSettledの使い分けにまとめてあります。

まとめ|非同期関数の書き方で押さえる要点

  • async を付けた関数は、何を return しても戻り値がPromiseに包まれます。return 1Promise<number> です。
  • await を書けるのは非同期関数の中と、ESモジュールのトップレベルだけです。CommonJS(require)のファイル直下では構文エラーになります。
  • TypeScriptの戻り値型は Promise<T> と書き、受け取り側で T を取り出すときは Awaited<T> を使います。
  • delay(sleep)は setTimeout をPromiseで包む3行で書けます。Node.jsなら node:timers/promisessetTimeout がそのまま使えます。
  • 同期処理を async で包むだけの関数は、呼び出し側の型と実行順序をPromiseに巻き込む分だけ損です。非同期にする理由がないなら付けません。

非同期関数の宣言|asyncを付けた関数が返すもの

戻り値が自動でPromiseに包まれる規則

async の効果は2つだけです。関数の戻り値をPromiseで包むことと、その中で await を使えるようにすること。数値を返しても文字列を返しても、呼び出し側が受け取るのはPromiseです。

async function loadCount() {
  return 1;                // 呼び出し側には Promise<number> が返る
}

const p = loadCount();     // p は Promise。ここでは中身の 1 は取り出せない
console.log(p);            // Promise { 1 }
console.log(await p);      // 1

async function loadUser() {
  return fetch('/api/user').then((r) => r.json());  // Promise を返しても二重には包まれない
}

返した値がPromise(正確には then を持つオブジェクト)だった場合、二重には包まれず、解決済みの値に平坦化されます。return await somethingreturn something がほぼ同じ結果になるのはこのためです。違いが出るのは try/catch の内側で、await を省くとその関数のcatchでは捕まらなくなります。

コールバック関数からPromise・async/awaitへの移行理由

コールバック関数とは、別の関数に引数として渡し、処理が終わった時点で呼び出してもらう関数のことです。非同期処理の結果を受け取る手段として長く使われてきましたが、結果を使って次の非同期処理を呼ぶ、という連鎖を書くとネストが深くなります。

// コールバック方式:エラー処理が各段に散らばる
getUser(id, (err, user) => {
  if (err) return handle(err);
  getOrders(user.id, (err, orders) => {
    if (err) return handle(err);
    getInvoice(orders[0], (err, invoice) => { /* ... */ });
  });
});

// async/await:手続きの順序がそのまま縦に並ぶ
const user = await getUser(id);
const orders = await getOrders(user.id);
const invoice = await getInvoice(orders[0]);

コールバックが消えたわけではありません。addEventListener や配列の map のように、「呼ばれる回数が1回とは限らない」処理は今もコールバックが担当します。1回だけ完了を知らせる処理がPromiseに置き換わった、と捉えると使い分けを間違えません。

await式の書ける場所と再開のタイミング

非同期関数の中と、ESモジュールのトップレベル

await が書けるのは async を付けた関数の中です。加えて、ESモジュール(type: "module" 指定または .mjs)に限りファイル直下でも書けます。これがトップレベルawaitで、CommonJSのファイルでは使えません。

// ESモジュール(package.json に "type": "module")
const config = await loadConfig();   // ファイル直下で await できる
export default config;

// CommonJS では SyntaxError。即時実行関数で包む必要がある
(async () => {
  const config = await loadConfig();
})();

トップレベルawaitを使ったモジュールは、それをimportした側の評価も待たせます。起動時の設定読み込みのように「無いと先に進めない」処理に向き、リクエストごとの取得のような繰り返し呼ばれる処理には向きません。

await後の再開順序とマイクロタスク

await は関数の実行をそこで中断し、同期的なコードを先に最後まで走らせます。再開されるのはPromiseが解決した後で、しかも setTimeout より先です。両者はキューが別で、Promiseの継続(マイクロタスク)が優先されます。

async function main() {
  console.log('1');
  await null;              // 値でも await できる。ここで一旦抜ける
  console.log('3');
}

setTimeout(() => console.log('4'), 0);
main();
console.log('2');
// 出力順: 1 → 2 → 3 → 4

await null のように解決済みの値を待った場合でも、その場で続きが走ることはありません。「awaitを書いた行より後ろは、必ず一度イベントループに制御が戻ってから実行される」と覚えておくと、ログの順序が想定と違う場面で迷わなくなります。

TypeScriptでの非同期関数の型定義

戻り値はPromise<T>、取り出すときはAwaited<T>

TypeScriptでは async 関数の戻り値型を Promise<T> と書きます。T をそのまま書くとコンパイルエラーになります。逆に、既存の関数の戻り値からPromiseを剥がした型が欲しいときは Awaited<T> を使います。

type User = { id: string; name: string };

async function fetchUser(id: string): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  return (await res.json()) as User;
}

// 戻り値の中身の型だけを取り出す
type FetchedUser = Awaited<ReturnType<typeof fetchUser>>;  // User

// 配列で待つ場合も中身の型は保たれる
const users: User[] = await Promise.all(ids.map((id) => fetchUser(id)));

res.json() の戻り値は any です。as User はコンパイラを黙らせるだけで、実行時に形が違えばそのまま通ります。外部APIの応答を型で受けるなら、unknown で受けてからバリデーションライブラリで検証する方が安全です。型注釈は実行時の保証ではない、という前提を崩さないことが、非同期処理では特に効きます。

catch変数の型とコンパイラ設定

strict を有効にすると useUnknownInCatchVariables も入り、catch の変数は unknown になります。e.message を直接読むとエラーになるため、絞り込みが要ります。

try {
  await fetchUser('u1');
} catch (e: unknown) {
  if (e instanceof Error) {
    console.error(e.message);       // ここでは Error に絞り込まれている
  } else {
    console.error(String(e));       // 文字列など Error 以外も throw できる
  }
}

コンパイラ本体は2026年7月8日公開の7.0.2が最新で、Goで書き直されたネイティブ版に移行しています(移行の要点はTypeScript 7.0とは?Goネイティブ移植で型チェックを約10倍速くする仕組みを解説を参照)。async/awaitの構文自体は変わっていませんが、targetES2017 より低く設定するとasync関数はジェネレータへ変換されるため、出力コードの姿は設定で変わります。捕まえた後の扱い(再throw・原因の連結・上位への伝播)はJavaScriptの非同期エラーハンドリングと並列実行|try/catch・Promise.all・allSettledの使い分けで扱います。

delay関数の実装|setTimeoutをPromiseにする書き方

3行で書けるdelay関数

JavaScriptに標準のsleepはありません。setTimeout をPromiseで包むのが定番で、TypeScriptなら引数と戻り値に型を付けるだけです。

// TypeScript
export const delay = (ms: number): Promise<void> =>
  new Promise((resolve) => setTimeout(resolve, ms));

// 値を返したい場合
export const delayValue = <T>(ms: number, value: T): Promise<T> =>
  new Promise((resolve) => setTimeout(() => resolve(value), ms));

await delay(1000);   // 1秒待ってから次の行へ

setTimeout の指定時間は「最短の待ち時間」です。タイマーが切れてもコールバックはキューに並ぶだけなので、同期処理が詰まっていれば実際の再開はそれより後になります。ミリ秒単位の精度が要る処理をdelayで組まないでください。

Node.js標準のnode:timers/promises版setTimeout

Node.jsには待機用のPromise版タイマーが標準で入っています。自作のdelayを置く必要はありません。第3引数で AbortSignal を渡せば、待機中の中断もできます。

import { setTimeout as sleep } from 'node:timers/promises';

await sleep(1000);                       // 1秒待つ
const value = await sleep(1000, 'done'); // 解決値を指定できる

// 中断できる待機
const ac = new AbortController();
setTimeout(() => ac.abort(), 300);       // グローバル側のsetTimeout
try {
  await sleep(1000, undefined, { signal: ac.signal });
} catch (e) {
  console.log('待機を中断しました');       // AbortError
}

ブラウザ側には同等の標準APIがないため、自作のdelayを使います。中断が要る場合は AbortSignalabort イベントで resolve を呼ぶ実装を足すか、そもそも待機ではなくタイムアウト付きの処理として組み直す方が素直です。

delayでポーリングを書くときの上限

「終わるまで一定間隔で問い合わせる」処理をdelayで書く場合、回数の上限を必ず持たせます。上限のないループは、相手が壊れているときに永久に待ち続けます。

async function waitForReady(id: string, maxAttempts = 10): Promise<boolean> {
  for (let i = 0; i < maxAttempts; i++) {
    if (await isReady(id)) return true;
    await delay(1000 * 2 ** i);   // 1秒、2秒、4秒... と間隔を広げる
  }
  return false;
}

間隔を毎回同じにすると、復旧直後に全クライアントが同時に殺到します。上の例のように待ち時間を倍にしていく指数バックオフと、全体のタイムアウトを組み合わせるのが実務での既定です。リトライ側の設計(何回まで、どの例外なら再試行してよいか、冪等性の担保)はJavaScriptの非同期エラーハンドリングと並列実行|try/catch・Promise.all・allSettledの使い分けで扱います。

asyncを付けるべきでない4つの場面

非同期関数は書けば書くほど良いものではありません。次の4つは、付けた側が損をする典型です。

// 1. 同期処理を async で包むだけ:呼び出し側が必ず await を強いられる
async function toUpper(s: string) { return s.toUpperCase(); }

// 2. forEach に async を渡す:Promise が捨てられ、待たれない
items.forEach(async (i) => { await save(i); });   // 保存完了前に次の行へ進む
for (const i of items) { await save(i); }         // 直列でよければこちら

// 3. コンストラクタ内で await したい:構文上できない。ファクトリ関数にする
class Client {
  private constructor(readonly token: string) {}
  static async create() { return new Client(await getToken()); }
}

4つ目はテストでの固定待ちです。await delay(500) で「たぶん終わっているはず」を待つテストは、CIの負荷が上がった日に落ちます。完了イベントや戻り値のPromiseなど、明示的な合図を待ってください。1と2は特に見落とされやすく、1は型が Promise<string> になることで呼び出し側の連鎖まで非同期に引きずり、2は「保存されていないのにレスポンスを返す」というデータの取りこぼしにつながります。

よくある質問

非同期関数とは何ですか?

処理の完了を待たずに呼び出し元へ制御を返す関数で、JavaScriptでは async を付けて宣言します。戻り値は必ずPromiseになり、中で await を書けます。実行が「速くなる」わけではなく、待ち時間の間に他の処理を進められるようになる、という性質の違いです。

TypeScriptでdelay(sleep)はどう書きますか?

const delay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms)); と書き、await delay(1000) で使います。Node.jsで動かすコードなら node:timers/promisessetTimeout をimportすれば自作は不要です。

awaitはトップレベルで使えますか?

ESモジュールなら使えます。package.json"type": "module" を書いたプロジェクトか .mjs ファイルが対象です。CommonJSでは構文エラーになるため、即時実行の非同期関数で包みます。

コールバック関数とは何ですか?

他の関数に引数として渡し、処理の完了時やイベント発生時に呼び出してもらう関数です。1回だけ完了を知らせる用途はPromiseとasync/awaitに置き換わりましたが、複数回発火するイベント処理では今もコールバックが標準の書き方です。

async関数の戻り値をそのまま変数に入れられないのはなぜですか?

戻り値がPromiseだからです。const n = loadCount();n はPromiseオブジェクトで、中身の値ではありません。await を付けるか .then() で受け取ります。console.logPromise { <pending> } と出るのはこの状態です。

関連記事

資料請求

RELATED POSTS 関連記事