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 Result ein. 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 setTimeout nicht auf, wenn das promise zuerst abgeschlossen wird. Im Produktionscode sollten Sie die Timer-ID speichern und clearTimeout in finally aufrufen. Alternativ können Sie AbortSignal.timeout(ms) verwenden, wenn die zugrunde liegende API signal unterstü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 await vor expect(...).resolves/.rejects. Ohne await endet der Test, bevor das Promise abgeschlossen ist, und besteht immer.
  • .rejects.toThrow(...) prüft die Meldung des abgelehnten Fehlers.
  • expectTypeOf führt zur Laufzeit keine Logik aus. Das Flag --typecheck zwingt Vitest, tsc auszuführen, um Typen zu überprüfen. Wenn jemand ändert, dass withTimeout Promise<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 Sie async weglassen. Behalten Sie async bei, wenn die Funktion try/catch um await hat. Wenn Sie in diesem Fall async und await weglassen, kann catch abgelehnte 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.

typescriptasyncpromisegenericsvitest