Typen für async-Funktionen und Promises in TypeScript
1. Übersicht
async-Funktionen finden sich in praktisch allem modernem TypeScript-Code: API-Aufrufe, Datenbankzugriffe, Verarbeitung von Warteschlangen. Eine async-Funktion zu schreiben ist einfach. Sie zu typisieren, damit der Compiler Fehler finden kann, ist schwieriger.
Dieser Artikel zeigt, wie Sie Typen für async-Funktionen und Promise definieren. Die Hauptthemen sind Promise<T>, Awaited<T>, Typen für Promise.all und Promise.allSettled, typsichere Fehlerbehandlung und generische Helper wie Timeout. Danach können Sie subtile Fehler vermeiden, wie das Vergessen von await oder die Verwendung von async in forEach.
2. Umgebung einrichten
Die Beispiele verwenden TypeScript im strict-Modus und Vitest zum Testen:
npm init -y
npm install -D typescript vitest
npx tsc --init --strict --target es2022 --module nodenext
Das Flag --strict aktiviert automatisch useUnknownInCatchVariables. Dieses Flag ist wichtig für die Fehlerbehandlung in Abschnitt 5.
Alle Beispiele verwenden ein einfaches Modell und eine Funktion, die Netzwerkverzögerung simuliert:
// 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 gibt Promise<void> zurück. Das ist der Typ eines Promise, das nur ‘fertig’ anzeigt, ohne einen Wert zu liefern. Hier müssen Sie explizit new Promise<void> schreiben. Ohne explizite Angabe würde TypeScript Promise<unknown> herleiten.
3. Rückgabetyp von async-Funktionen
3.1. Immer Promise<T>
async-Funktionen geben immer ein Promise zurück. Der Wert, den Sie zurückgeben, wird darin eingewickelt:
// 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(`Bestellung ${id} nicht gefunden`);
}
return order; // Typ Order, eingewickelt in Promise<Order>
}
export async function fetchCustomer(id: string): Promise<Customer> {
await delay(30);
return { id, name: 'Nguyễn Văn An' };
}
Der deklarierte Typ ist Promise<Order>, nicht Order. Wenn Sie async function fetchOrder(id: string): Order schreiben, meldet der Compiler einen Fehler:
error TS1064: The return type of an async function or method must be the global Promise<T> type.
Die throw-Anweisung innerhalb einer async-Funktion ändert nicht den Rückgabetyp. Der Fehler wird in ein abgelehntes Promise umgewandelt. Der Aufrufer kann es mit try/catch um await oder mit .catch() abfangen.
3.2. Typinferenz oder explizite Deklaration?
TypeScript leitet den Rückgabetyp automatisch her, wenn Sie die Typannotation weglassen. Dennoch sollten Sie bei öffentlichen Funktionen den Typ explizit angeben. Siehe das folgende Beispiel:
async function findOrder(id: string) {
const order = orders.get(id);
if (!order) return null;
return order;
}
// Hergeleiteter Typ: Promise<Order | null>
Dieser Typ ist korrekt, aber er hängt vom Funktionsrumpf ab. Angenommen, jemand ändert den ersten Zweig zu return undefined. Der Typ würde stillschweigend zu Promise<Order | undefined> ändern, und alle Stellen, die auf === null prüfen, würden fehlerhaft sein, ohne dass eine Warnung ausgegeben wird. Wenn Sie Promise<Order | null> explizit schreiben, meldet der Compiler sofort einen Fehler in der Funktion.
4. Den Typ ‘innerhalb’ eines Promise mit Awaited<T> extrahieren
Manchmal haben Sie eine async-Funktion und brauchen den Typ des Werts nach await. ReturnType reicht nicht aus, da es das Promise selbst zurückgibt:
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> simuliert das Verhalten von await genau. Es ‘entfernt’ das Promise rekursiv, auch wenn Promises verschachtelt sind. Dieser Typ ist sehr nützlich, wenn Sie Generics schreiben oder wenn eine Bibliothek keinen Ergebnistyp exportiert:
type OrderSummary = Pick<Awaited<ReturnType<typeof fetchOrder>>, 'id' | 'total'>;
// { id: string; total: number }
5. Typen für Fehler: Promise hat keinen Fehlerparameter
Promise<T> hat nur einen Typparameter für den Erfolgsfall. Es gibt keine Typsyntax wie Promise<Order, NotFoundError>. Daher hat die Variable in catch den Typ 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('Fehler:', err.message);
} else {
console.error('Unbekannter Fehler:', err);
}
}
}
await printOrder('o-404');
Fehler: Bestellung o-404 nicht gefunden
JavaScript erlaubt throw mit beliebigen Werten: Strings, Zahlen, Objekten. Daher ist unknown der einzige ehrliche Typ für err. Sie müssen den Typ mit instanceof eingrenzen, bevor Sie auf err.message zugreifen.
Wenn Sie Fehler als Teil des Typs haben möchten, können Sie das Result-Muster verwenden:
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
Die Eigenschaft ok ist ein Diskriminator. Nach der Prüfung if (result.ok) weiß TypeScript sicher, dass result.value existiert und den Typ Order hat. Der Aufrufer kann die Fehlerbehandlung nicht ‘vergessen’, da er ok prüfen muss, bevor er value abruft.
Hinweis: Wickeln Sie nicht jede Funktion mit
Resultein. Dieses Muster eignet sich für erwartete geschäftliche Fehler wie ‘nicht gefunden’ oder ‘nicht verfügbar’. Bei Infrastrukturfehlern wie verlorener Datenbankverbindung lassen Sie die Exception zur zentralen Fehlerbehandlung aufsteigen.
6. Typen für Promise.all, allSettled und race
6.1. Promise.all erhält den Tuple-Typ
Wenn Sie ein Array-Literal übergeben, leitet TypeScript den Typ für jede Position her:
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
Die beiden Anfragen laufen parallel. Die Gesamtzeit beträgt etwa 50 ms, nicht 80 ms. Der Ergebnistyp ist [Order, Customer], nicht (Order | Customer)[]. Dadurch können Sie destructuring verwenden, ohne den Typ zu casten.
Dies gilt nur, wenn Sie das Array direkt übergeben. Wenn Sie das Array zuerst erstellen und dann übergeben, geht der Tuple-Typ verloren:
const tasks = [fetchOrder('o-1'), fetchCustomer('c-1')];
const results = await Promise.all(tasks);
// results: (Order | Customer)[] -- Positionsinformation geht verloren
const tasksTuple = [fetchOrder('o-1'), fetchCustomer('c-1')] as const;
const [o, c] = await Promise.all(tasksTuple); // Order, Customer
Das Hinzufügen von as const hilft, den Tuple-Typ beizubehalten.
6.2. Promise.allSettled und Typeingrenzung
Promise.allSettled lehnt nie ab. Jedes Element im Ergebnis hat den Typ 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('FAIL', r.reason); // r.reason: any
}
}
OK o-1
FAIL Error: Bestellung o-404 nicht gefunden
r.reason hat den Typ any, nicht unknown. Dies ist eine kleine Lücke in der Standard-Bibliothek, daher sollten Sie immer noch vor der Verwendung auf instanceof Error prüfen.
6.3. Promise.race und generischer Timeout-Helper
Promise.race gibt einen Union-Typ aller Promises zurück. Kombiniert mit Promise<never> erhalten wir einen Timeout-Helper, der den Typ beibehält:
export function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error(`Timeout nach ${ms}ms`)), ms),
);
return Promise.race([promise, timeout]);
}
const order = await withTimeout(fetchOrder('o-1'), 1000); // order: Order
Da timeout den Typ Promise<never> hat, ist das Ergebnis von race Promise<T | never>, also Promise<T>. never verschwindet aus dem Union-Typ. Das ist genau seine Bedeutung: ‘dieser Zweig gibt nie einen Wert zurück’.
Hinweis: Das obige Beispiel räumt den
setTimeoutnicht auf, wenn daspromisezuerst abgeschlossen wird. Im Produktionscode sollten Sie die Timer-ID speichern undclearTimeoutinfinallyaufrufen. Alternativ können SieAbortSignal.timeout(ms)verwenden, wenn die zugrunde liegende APIsignalunterstützt.
7. Typen für async-Callbacks
7.1. Die () => void-Falle
TypeScript erlaubt die Zuweisung einer Funktion, die Promise<void> zurückgibt, zu einem () => void-Typ. Dies sieht harmlos aus, kann aber zu Fehler führen, die verloren gehen:
function onOrderPaid(handler: (order: Order) => void) {
const order: Order = { id: 'o-1', customerId: 'c-1', total: 250_000, status: 'PAID' };
handler(order); // Das zurückgegebene Promise wird ignoriert
console.log('Handler aufgerufen');
}
onOrderPaid(async (order) => {
await delay(10);
throw new Error('Email-Versand fehlgeschlagen'); // unhandled rejection!
});
Handler aufgerufen
Error: Email-Versand fehlgeschlagen (unhandled rejection, kann Node-Prozess zum Absturz bringen)
onOrderPaid weiß nicht, dass der Handler async ist. Es hat kein await, daher wird der Fehler nicht abgefangen. Die Lösung besteht darin, explizit zu deklarieren, dass der Handler async sein kann, und ihn dann awaiten:
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); // await auf einem Nicht-Promise-Wert ist immer noch gültig
}
Der Typ void | Promise<void> akzeptiert sowohl synchrone als auch asynchrone Handler. await auf einem synchronen Wert gibt einfach diesen Wert zurück.
7.2. async in forEach
Dies ist der häufigste Fehler. forEach erwartet (item) => void, daher ignoriert es 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)); // immer noch ['PENDING', ...]
}
Es gibt zwei Lösungen, je nach Anforderung:
// Sequenziell: eins nach dem anderen
for (const o of list) {
await delay(20);
o.status = 'PAID';
}
// Parallel: Rückgabetyp ist Promise<void[]>
await Promise.all(
list.map(async (o) => {
await delay(20);
o.status = 'PAID';
}),
);
Um diese Typfehler automatisch zu erfassen, aktivieren Sie die Regeln @typescript-eslint/no-floating-promises und @typescript-eslint/no-misused-promises in typescript-eslint. Der TypeScript-Compiler allein meldet diese Fälle nicht.
8. Verhalten und Typen mit Vitest testen
Vitest ermöglicht das Testen sowohl von Werten zur Laufzeit als auch von Typen zur Kompilierzeit:
// 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('gibt Bestellung zurück, wenn sie existiert', async () => {
await expect(fetchOrder('o-1')).resolves.toMatchObject({
id: 'o-1',
status: 'PENDING',
});
});
it('lehnt ab, wenn Bestellung nicht gefunden wird', async () => {
await expect(fetchOrder('o-404')).rejects.toThrow('Bestellung o-404 nicht gefunden');
});
it('withTimeout lehnt ab, wenn promise zu lange dauert', async () => {
const slow = delay(200).then(() => 'fertig');
await expect(withTimeout(slow, 50)).rejects.toThrow('Timeout nach 50ms');
});
it('behält die Typen für Promise.all und withTimeout korrekt', () => {
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 }]>();
});
});
Test ausführen:
npx vitest run --typecheck
✓ order-service.test.ts (4 tests)
Test Files 1 passed (1)
Tests 4 passed (4)
Es gibt drei wichtige Punkte zu beachten:
- Immer
awaitvorexpect(...).resolves/.rejects. Ohneawaitendet der Test, bevor das Promise abgeschlossen ist, und besteht immer. .rejects.toThrow(...)prüft die Meldung des abgelehnten Fehlers.expectTypeOfführt zur Laufzeit keine Logik aus. Das Flag--typecheckzwingt Vitest,tscauszuführen, um Typen zu überprüfen. Wenn jemand ändert, dasswithTimeoutPromise<unknown>zurückgibt, wird dieser Test fehlschlagen.
9. Häufige Fehler
| Fehler | Folge | Lösung |
|---|---|---|
await vergessen bei async-Funktionsaufrufen |
Variable hat Typ Promise<Order> statt Order; Fehler werden verschluckt |
Aktivieren Sie no-floating-promises; Typ beim Hovern sorgfältig lesen |
if (fetchOrder(id)) |
Immer true, da Promise ein Objekt ist |
TypeScript 5.x meldet, dass Bedingung immer true ist; await vor der Prüfung |
async in forEach |
Wartet nicht, Fehler werden nicht abgefangen | for...of oder Promise.all(map) |
Zugriff auf err.message in catch |
Kompilierungsfehler bei aktiviertem strict |
Typ mit instanceof Error eingrenzen |
Casting as Promise<Order> für Daten von fetch |
Typ “lügt”, Fehler treten zur Laufzeit auf | Daten mit Schema validieren, dann Typ zuweisen |
Der letzte Fehler verdient weitere Erklärung. await response.json() gibt Promise<any> zurück. Das Schreiben von const order: Order = await res.json() kompiliert, aber TypeScript überprüft nichts. Daten von außerhalb des Prozesses müssen immer validiert werden.
Hinweis: Nicht jede Funktion, die ein Promise zurückgibt, benötigt das Keyword
async. Wenn eine Funktion nur ein Promise weiterleitet, z. B.return repository.find(id), können Sieasyncweglassen. Behalten Sieasyncbei, wenn die Funktiontry/catchumawaithat. Wenn Sie in diesem Fallasyncundawaitweglassen, kanncatchabgelehnte Fehler nicht abfangen.
10. Fazit
async-Funktionen geben immer Promise<T> zurück. Deklarieren Sie diesen Typ explizit für öffentliche APIs. Verwenden Sie Awaited<T>, wenn Sie den ‘entpackten’ Promise-Typ benötigen. Promise.all behält den Tuple-Typ bei, wenn Sie ein Array-Literal übergeben oder as const verwenden. Kombinieren Sie Promise.race mit Promise<never>, um generische Helper zu schreiben, ohne den Typ zu verlieren.
Was Fehler angeht: Promise hat keinen Fehlertyp. Die Variable in catch ist unknown, und das Result-Muster ist eine Option, wenn Sie Geschäftsfehler deutlich im Typ ausdrücken möchten. Seien Sie vorsichtig mit Callbacks vom Typ () => void und forEach. Aktivieren Sie ESLint-Regeln für Promise, um Fehler automatisch zu erfassen, die der Compiler übersieht.