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ルールを有効にして、コンパイラが見逃すエラーを自動的にキャッチします。