TypeScriptの非同期関数とPromiseの型付け

1. 概要

async関数は最新のTypeScriptコードのほぼ全てに存在します。API呼び出し、データベース読み込み、キュー処理に使われます。async関数を書くのは簡単です。コンパイラが型エラーをキャッチするよう正しく型を付けるのは、もっと難しいです。

このガイドでは、async関数とPromiseに型を付ける方法を説明します。主な内容はPromise<T>、Awaited<T>、Promise.allとPromise.allSettledの型、型安全なエラーハンドリング、そしてタイムアウトなどのジェネリックヘルパーです。読み終わるころには、awaitの忘却やasyncforEachでの利用といった、見つけづらいエラーを回避できるようになります。

2. 環境準備

例ではTypeScriptのstrictモードとVitestを使用します:

npm init -y
npm install -D typescript vitest
npx tsc --init --strict --target es2022 --module nodenext

--strictフラグはuseUnknownInCatchVariablesも有効にします。このフラグはセクション5のエラーハンドリングで重要です。

すべての例で共通のシンプルなモデルとネットワーク遅延をシミュレートする関数を使用します:

// models.ts
export interface Order {
  id: string;
  customerId: string;
  total: number;
  status: 'PENDING' | 'PAID';
}

export interface Customer {
  id: string;
  name: string;
}

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

delayはPromise<void>を返します。これは「完了した」を示すが値を持たないPromiseの型です。ここでnew Promise<void>と明示的に記述する必要があります。記述しない場合、TypeScriptはPromise<unknown>と推論します。

3. async関数の戻り値の型

3.1. 常にPromise<T>

async関数は常にPromiseを返します。内部でreturnした値がそこに包まれます:

// order-service.ts
import { Order, Customer, delay } from './models';

const orders = new Map<string, Order>([
  ['o-1', { id: 'o-1', customerId: 'c-1', total: 250_000, status: 'PENDING' }],
]);

export async function fetchOrder(id: string): Promise<Order> {
  await delay(50);
  const order = orders.get(id);
  if (!order) {
    throw new Error(`Order ${id} not found`);
  }
  return order; // 型Order、Promise<Order>に包まれる
}

export async function fetchCustomer(id: string): Promise<Customer> {
  await delay(30);
  return { id, name: 'Nguyễn Văn An' };
}

戻り値の型はPromise<Order>です。Orderではなく。もしasync function fetchOrder(id: string): Orderと書いたら、コンパイラはエラーを報告します:

error TS1064: The return type of an async function or method must be the global Promise<T> type.

async関数内のthrowは戻り値の型を変更しません。エラーはPromiseのrejectに変換されます。呼び出し側はawaitを囲むtry/catch、または.catch()でキャッチします。

3.2. 型推論か明示的な宣言か?

型注釈を省くと、TypeScriptは戻り値の型を自動的に推論します。ただ、publicなメソッドについては型を明示的に書くべきです。以下の例を見てください:

async function findOrder(id: string) {
  const order = orders.get(id);
  if (!order) return null;
  return order;
}
// 推論される型: Promise<Order | null>

この型は正しいですが、関数本体に依存しています。誰かが最初の分岐をreturn undefinedに変更したとします。型は暗黙のうちにPromise<Order | undefined>に変わり、=== nullチェックをしている呼び出し側すべてが間違ったままになります。警告も出ません。Promise<Order | null>を明示的に書くと、コンパイラはその関数で即座にエラーを報告します。

4. Awaited<T>でPromiseの「内側」の型を取得する

時々、async関数があり、await後の値の型が必要な場合があります。ReturnTypeでは不十分です。なぜなら、それはPromiseそのものを返すからです:

import { fetchOrder } from './order-service';

type R1 = ReturnType<typeof fetchOrder>;          // Promise<Order>
type R2 = Awaited<ReturnType<typeof fetchOrder>>; // Order

type Nested = Awaited<Promise<Promise<number>>>;  // number

Awaited<T>はawaitの動作を正確にシミュレートします。Promiseを再帰的に「開き」、ネストされたPromiseでも対応します。この型はジェネリックを書く場合、またはライブラリが結果の型をエクスポートしていない場合に非常に便利です:

type OrderSummary = Pick<Awaited<ReturnType<typeof fetchOrder>>, 'id' | 'total'>;
// { id: string; total: number }

5. エラーの型: Promiseには2つ目のパラメータがない

Promise<T>には1つの型パラメータしかありません。成功時の値用です。Promise<Order, NotFoundError>のような型構文はありません。そのため、catch内の変数はunknown型です:

async function printOrder(id: string): Promise<void> {
  try {
    const order = await fetchOrder(id);
    console.log(order.total);
  } catch (err) {
    // err: unknown
    if (err instanceof Error) {
      console.error('エラー:', err.message);
    } else {
      console.error('不明なエラー:', err);
    }
  }
}

await printOrder('o-404');
エラー: Order o-404 not found

JavaScriptは任意の値をthrowできます: 文字列、数値、オブジェクト。そのため、unknownがerrのための唯一の正確な型です。err.messageにアクセスする前に、instanceofで型を絞り込む必要があります。

エラーを型に含めたい場合は、Resultパターンを使用できます:

type Result<T, E = Error> =
  | { ok: true; value: T }
  | { ok: false; error: E };

async function attempt<T>(promise: Promise<T>): Promise<Result<T>> {
  try {
    return { ok: true, value: await promise };
  } catch (err) {
    const error = err instanceof Error ? err : new Error(String(err));
    return { ok: false, error };
  }
}

const result = await attempt(fetchOrder('o-1'));
if (result.ok) {
  console.log(result.value.status); // result.value: Order
} else {
  console.log(result.error.message);
}
PENDING

プロパティokはdiscriminantです。if (result.ok)をチェックした後、TypeScriptはresult.valueが存在し、型がOrderであることを確実に知っています。呼び出し側は「エラーハンドリングを忘れる」ことはできません。valueを取得したければ、まずokをチェックする必要があります。

注記: すべての関数をResultでラップしないでください。このパターンは予期されたビジネスエラー(「見つからない」、「在庫なし」など)に適しています。DBの接続切断のようなインフラエラーについては、例外を上位のハンドラに伝播させてください。

6. Promise.all、allSettled、raceの型

6.1. Promise.allはタプル型を保持する

リテラル配列を渡す場合、TypeScriptは各位置の型を推論します:

const [order, customer] = await Promise.all([
  fetchOrder('o-1'),
  fetchCustomer('c-1'),
]);
// order: Order, customer: Customer

console.log(`${customer.name} - ${order.total}`);
Nguyễn Văn An - 250000

2つのリクエストが並行実行されます。合計時間は50msほどです。80msではなく。結果の型は[Order, Customer]です。(Order | Customer)[]ではなく。そのため、型のキャストなしでdestructureできます。

これはリテラル配列を直接渡す場合にのみ機能します。最初に配列を作成してから渡す場合、タプル型は失われます:

const tasks = [fetchOrder('o-1'), fetchCustomer('c-1')];
const results = await Promise.all(tasks);
// results: (Order | Customer)[]  -- 位置情報が失われた

const tasksTuple = [fetchOrder('o-1'), fetchCustomer('c-1')] as const;
const [o, c] = await Promise.all(tasksTuple); // Order, Customer

as constを追加するとタプル型を保持できます。

6.2. Promise.allSettledと型の絞り込み

Promise.allSettledはrejectしません。結果内の各要素はPromiseSettledResult<T>型です:

const settled = await Promise.allSettled([
  fetchOrder('o-1'),
  fetchOrder('o-404'),
]);

for (const r of settled) {
  if (r.status === 'fulfilled') {
    console.log('OK', r.value.id);    // r.value: Order
  } else {
    console.log('失敗', r.reason);    // r.reason: any
  }
}
OK o-1
失敗 Error: Order o-404 not found

r.reasonはany型です。unknownではなく。これは標準ライブラリの小さな欠陥です。使う前にinstanceof Errorをチェックすべきです。

6.3. Promise.raceと汎用タイムアウトヘルパー

Promise.raceは複数のPromiseの型の和を返します。Promise<never>と組み合わせると、型を保持する汎用タイムアウトヘルパーが作れます:

export function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
  const timeout = new Promise<never>((_, reject) =>
    setTimeout(() => reject(new Error(`${ms}ms後にタイムアウト`)), ms),
  );
  return Promise.race([promise, timeout]);
}

const order = await withTimeout(fetchOrder('o-1'), 1000); // order: Order

timeoutはPromise<never>型なので、raceの結果はPromise<T | never>です。つまりPromise<T>です。neverは和型から消えます。これがneverの意味です: 「このブランチは値を返さない」。

注記: 上記の例は、promiseが先に完了した場合、setTimeoutをキャンセルしません。プロダクションコードでは、タイマーIDを保存してfinallyでclearTimeoutを呼ぶべきです。別の方法は、下位のAPIがsignalをサポートしている場合、AbortSignal.timeout(ms)を使用することです。

7. async callbackの型

7.1. () => voidの落とし穴

TypeScriptはPromise<void>を返す関数を() => void型に割り当てることを許可します。無害に見えますが、エラーを失う可能性があります:

function onOrderPaid(handler: (order: Order) => void) {
  const order: Order = { id: 'o-1', customerId: 'c-1', total: 250_000, status: 'PAID' };
  handler(order); // 返されたPromiseが無視される
  console.log('ハンドラが呼ばれました');
}

onOrderPaid(async (order) => {
  await delay(10);
  throw new Error('メール送信失敗'); // ハンドルされないreject!
});
ハンドラが呼ばれました
Error: メール送信失敗   (ハンドルされないreject。Nodeプロセスをクラッシュさせる可能性あり)

onOrderPaidはhandlerがasyncであることを知りません。awaitしないため、エラーは誰にもキャッチされません。修正方法は、handlerが非同期である可能性があることを明示的に宣言し、それをawaitすることです:

type OrderHandler = (order: Order) => void | Promise<void>;

async function onOrderPaidSafe(handler: OrderHandler): Promise<void> {
  const order: Order = { id: 'o-1', customerId: 'c-1', total: 250_000, status: 'PAID' };
  await handler(order); // Promise以外の値をawaitしてもOK
}

void | Promise<void>型は同期と非同期の両方のhandlerを受け入れます。Promiseでない値にawaitすると、その値がそのまま返ります。

7.2. forEachでのasync

これは最も一般的なエラーです。forEachは(item) => voidを受け入れるため、Promiseを無視します:

async function payAll(list: Order[]): Promise<void> {
  list.forEach(async (o) => {
    await delay(20);
    o.status = 'PAID';
  });
  console.log(list.map((o) => o.status)); // まだ ['PENDING', ...]
}

ニーズに応じて、2つの修正方法があります:

// 順序通り: 1つずつ
for (const o of list) {
  await delay(20);
  o.status = 'PAID';
}

// 並行: 戻り値の型は Promise<void[]>
await Promise.all(
  list.map(async (o) => {
    await delay(20);
    o.status = 'PAID';
  }),
);

これらのエラーを自動的にキャッチするには、typescript-eslintで@typescript-eslint/no-floating-promisesと@typescript-eslint/no-misused-promisesルールを有効にします。TypeScriptコンパイラ単体ではこれらのケースについてはエラーを報告しません。

8. Vitestで動作と型をテストする

Vitestではruntimeの値とcompile時の型の両方をテストできます:

// order-service.test.ts
import { describe, it, expect, expectTypeOf } from 'vitest';
import { fetchOrder, fetchCustomer } from './order-service';
import { withTimeout } from './with-timeout';
import { delay, type Order } from './models';

describe('order-service', () => {
  it('存在する場合は注文を返す', async () => {
    await expect(fetchOrder('o-1')).resolves.toMatchObject({
      id: 'o-1',
      status: 'PENDING',
    });
  });

  it('見つからない場合はreject', async () => {
    await expect(fetchOrder('o-404')).rejects.toThrow('Order o-404 not found');
  });

  it('Promiseが長すぎるとwithTimeoutはreject', async () => {
    const slow = delay(200).then(() => '完了');
    await expect(withTimeout(slow, 50)).rejects.toThrow('50ms後にタイムアウト');
  });

  it('Promise.allとwithTimeoutの型を保持する', () => {
    expectTypeOf(fetchOrder).returns.resolves.toEqualTypeOf<Order>();
    expectTypeOf(withTimeout(fetchOrder('o-1'), 100)).resolves.toEqualTypeOf<Order>();
    expectTypeOf(
      Promise.all([fetchOrder('o-1'), fetchCustomer('c-1')]),
    ).resolves.toMatchTypeOf<[Order, { name: string }]>();
  });
});

テストを実行します:

npx vitest run --typecheck
 ✓ order-service.test.ts (4 tests)
 Test Files  1 passed (1)
      Tests  4 passed (4)

3つの注意点があります:

  • 常にawaitをexpect(...).resolves/.rejectsの前に付けてください。ない場合、テストはPromiseが完了する前に終了し、常にパスします。
  • .rejects.toThrow(...)はrejectされたエラーのメッセージをチェックします。
  • expectTypeOfはruntimeで何も実行しません。--typecheckフラグはVitestにtscを実行するよう指示し、型を確認します。誰かがwithTimeoutをPromise<unknown>を返すように変更したら、このテストはfailします。

9. よくあるエラー

エラー 結果 修正
awaitを忘れる 変数がPromise<Order>型に; エラーが失われる no-floating-promisesを有効化; hoverで型を確認
if (fetchOrder(id)) 常にtrue (Promiseはオブジェクト) TypeScript 5.x は常に真の条件を報告; awaitを先に
forEachでのasync 待たない、エラーがキャッチされない for...ofまたはPromise.all(map)
catchでerr.messageにアクセス strict有効時のコンパイルエラー instanceof Errorで絞り込む
外部データにas Promise<Order>でキャスト 型が「嘘をつく」、エラーはruntimeで発生 スキーマでデータをvalidate、その後型付け

最後のエラーについて、さらに説明します。await response.json()はPromise<any>を返します。const order: Order = await res.json()と書くとコンパイルできますが、TypeScriptは何もチェックしません。プロセス外からのデータは常にvalidateが必要です。

注記: Promiseを返すすべての関数がasyncキーワードが必要とは限りません。関数がPromiseを単に中継するだけの場合、例えばreturn repository.find(id)なら、asyncを省くことができます。awaitを囲むtry/catchがある場合はasyncを保ちます。asyncとawaitを省いた場合、catchはrejectされたエラーをキャッチできません。

10. まとめ

async関数は常にPromise<T>を返します。publicなAPIについては、この型を明示的に宣言してください。Promise内の型が必要な場合はAwaited<T>を使用します。リテラル配列を渡すかas constを使用すれば、Promise.allはタプル型を保持します。Promise.raceとPromise<never>を組み合わせて、型を失わないジェネリックヘルパーを書くことができます。

エラーについて、Promiseはエラーの型を持ちません。catch内の変数はunknownです。Resultパターンはビジネスエラーを型に含めたい場合の選択肢です。最後に、() => void型のコールバックとforEachに注意してください。PromiseについてのESLintルールを有効にして、コンパイラが見逃すエラーを自動的にキャッチします。

typescriptasyncpromisegenericsvitest