Die Fetch API typsicher wrappen
<T>
type
TypeScript · Fetch API · Generics · API Client
Die Fetch API typsicher wrappen
Generischer Request-Wrapper statt any-Response

Wer fetch() direkt verwendet, bekommt von TypeScript keinerlei Garantie über die Form der Antwort: response.json() liefert any, und ein einfacher Generic-Parameter validiert zur Laufzeit gar nichts. Dieser Artikel zeigt, wie ein generischer Fetch-Wrapper mit typisierten Fehlerergebnissen, Timeout-Handling und einem praktischen API-Client echte Typsicherheit für Magento- und Headless-Projekte schafft.

12 Min. Lesezeit Generics · Result Types · AbortController TypeScript 5 · Fetch API · REST

1. Das Problem: fetch() liefert keinen typsicheren Response-Body

Die native fetch()-Funktion ist bewusst generisch gehalten: Sie kennt weder das Zielformat der Antwort noch die Struktur der Daten, die ein Server zurückgibt. Response.json() gibt deshalb Promise<any> zurück, egal ob die API Produkte, Kundenadressen oder eine Fehlermeldung liefert. In kleinen Skripten fällt das kaum auf, aber in einem wachsenden Magento- oder Headless-Frontend mit Dutzenden Endpunkten wird jede any-Stelle zu einem blinden Fleck, an dem der Compiler keine Fehler mehr findet.

Besonders tückisch: Ein Tippfehler im Property-Namen, eine geänderte API-Version oder ein leerer Response-Body führen nicht zu einem Compile-Fehler, sondern erst zur Laufzeit zu einem undefined oder einer Exception, oft tief verschachtelt in einer Komponente, die den ursprünglichen Request gar nicht kennt. Wer TypeScript nutzt, um genau solche Fehler früh zu erkennen, verliert diesen Vorteil an der Netzwerkgrenze wieder, wenn fetch() unbehandelt bleibt.

2. Warum ein Generic allein nichts validiert

Ein naheliegender erster Schritt ist, dem Aufruf einen Typparameter mitzugeben, etwa fetch(url).then(r => r.json()) as Product oder eine Wrapper-Funktion mit <T>. Das Problem: Ein Generic ist zur Laufzeit vollständig verschwunden. TypeScript kompiliert zu JavaScript, und JavaScript kennt keine Typen mehr. Der Ausdruck as T ist eine reine Type Assertion, also eine Behauptung an den Compiler: "Vertrau mir, das ist ein T." Der Compiler prüft diese Behauptung nicht gegen die tatsächlichen Daten, er übernimmt sie einfach.

Das erzeugt eine gefährliche Illusion von Sicherheit: Der Code sieht typsicher aus, IntelliSense schlägt korrekte Properties vor, und trotzdem crasht die Anwendung, sobald die API eine unerwartete Struktur liefert. Diese Lücke lässt sich mit reinem TypeScript nicht schließen, sie erfordert entweder diszipliniertes Wrapper-Design, das Fehlerfälle explizit modelliert, oder eine echte Laufzeit-Validierung, die weiter unten in diesem Artikel angerissen wird. Der erste Schritt ist aber, überhaupt zu verstehen, wo genau die Grenze zwischen Compile-Zeit und Laufzeit verläuft.

3. Einen generischen Fetch-Wrapper bauen: request<T>()

Der pragmatische Mittelweg ist ein zentraler Fetch-Wrapper, der alle Requests durch eine einzige, typisierte Funktion leitet, statt fetch() über die gesamte Codebasis verstreut aufzurufen. Eine Funktion wie request<T>(url, options): Promise<T> bündelt Standardverhalten wie Header, Statuscode-Prüfung und Fehlerbehandlung an einer Stelle und macht den Rückgabetyp an jedem Call-Site explizit sichtbar, statt ihn implizit aus dem Kontext zu erraten.

Wichtig ist, den Wrapper so zu bauen, dass die Fehlerprüfung vor der Typumwandlung passiert: response.ok muss geprüft werden, bevor response.json() aufgerufen wird, sonst landet eine Fehlermeldung des Servers fälschlich im typisierten Erfolgspfad. Das folgende Beispiel zeigt die Grundstruktur, die anschließend in den nächsten Abschnitten um Fehler-Typen, Timeout und Retry-Logik erweitert wird.


// Generic typed fetch wrapper - the caller decides what shape T has
async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
  const response = await fetch(url, {
    ...options,
    headers: {
      'Content-Type': 'application/json',
      ...options.headers,
    },
  });

  if (!response.ok) {
    throw new Error(`Request failed: ${response.status} ${response.statusText}`);
  }

  // response.json() returns Promise<any> - the cast below is a promise, not a guarantee
  return response.json() as Promise<T>;
}

interface Product {
  sku: string;
  name: string;
  price: number;
}

// TypeScript trusts this completely at compile time, even if the API returns something else
const product = await request<Product>('/rest/V1/products/24-MB01');

4. HTTP-Fehler behandeln: typisierte Error-Results statt Exceptions

Exceptions sind in TypeScript unsichtbar für den Compiler: Eine Funktionssignatur wie Promise<Product> verrät nicht, dass sie bei einem 404 oder 500 auch werfen kann. Aufrufende Stellen vergessen dadurch regelmäßig, try/catch zu verwenden, und der Fehler propagiert unkontrolliert nach oben. Ein robusteres Muster stammt aus funktionalen Sprachen: ein Result- beziehungsweise Either-Type, der Erfolg und Fehler als discriminated union explizit im Rückgabetyp abbildet.

Mit einem diskriminierenden Feld wie ok: true beziehungsweise ok: false kann TypeScript den Typ nach einer einfachen if-Prüfung automatisch verfeinern (narrowing): Im Erfolgszweig ist data vom Typ T verfügbar, im Fehlerzweig status und error. Aufrufer werden so vom Compiler gezwungen, beide Fälle zu behandeln, statt Fehler stillschweigend zu ignorieren oder erst zur Laufzeit von einer unbehandelten Exception überrascht zu werden.


type ApiResult<T> =
  | { ok: true; data: T }
  | { ok: false; status: number; error: string };

async function safeRequest<T>(url: string, options: RequestInit = {}): Promise<ApiResult<T>> {
  try {
    const response = await fetch(url, options);

    if (!response.ok) {
      // Try to parse a structured error body, fall back to statusText
      const body = await response.json().catch(() => null) as { message?: string } | null;
      return { ok: false, status: response.status, error: body?.message ?? response.statusText };
    }

    const data = (await response.json()) as T;
    return { ok: true, data };
  } catch (err) {
    return { ok: false, status: 0, error: err instanceof Error ? err.message : 'Network error' };
  }
}

const result = await safeRequest<Product>('/rest/V1/products/24-MB01');

if (result.ok) {
  // TypeScript narrows result.data to Product here
  console.log(result.data.sku);
} else {
  // TypeScript narrows to the error branch here
  console.error(`Error ${result.status}: ${result.error}`);
}

5. Request-Payloads, Bodies und Headers typisieren

Typsicherheit betrifft nicht nur die Antwort, sondern genauso den Request-Body. Eine Wrapper-Funktion, die für POST- und PUT-Anfragen ein generisches TBody akzeptiert und intern JSON.stringify() aufruft, verhindert, dass ein falsch benanntes Feld erst beim Server als 400-Fehler auffällt. Besonders nützlich sind Utility-Types wie Partial<T> für PATCH-Requests oder Omit<Product, 'sku'> für Create-Endpunkte, bei denen der Server die ID selbst vergibt.

Auch Header profitieren von Typisierung: Ein HeadersInit-Objekt mit klar benannten Konstanten für Authorization, Content-Type oder projektspezifische Header wie X-Store-Code verhindert Tippfehler, die sonst erst im Netzwerk-Tab sichtbar werden. In Magento-Headless-Setups mit Bearer-Token-Authentifizierung lohnt sich eine kleine buildHeaders()-Hilfsfunktion, die Token-Handling zentralisiert und den Wrapper von Auth-Details entkoppelt, damit dieser für alle Ressourcen wiederverwendbar bleibt.

6. Timeout und AbortController typsicher einbinden

Ohne explizites Timeout hängt ein fetch()-Aufruf im schlimmsten Fall unbegrenzt, etwa wenn ein Backend unter Last nicht mehr antwortet. Die AbortController API löst das nativ: Ein AbortController erzeugt ein AbortSignal, das an fetch() übergeben wird, und ein setTimeout() ruft bei Überschreitung controller.abort() auf. Wichtig für die Typisierung: Ein abgebrochener Request wirft eine DOMException mit name === 'AbortError', die sich sauber von echten Netzwerkfehlern unterscheiden lässt.

Kombiniert man das Timeout-Pattern mit dem Result-Type aus dem vorherigen Abschnitt, wird ein Timeout zu einem regulären, typisierten Fehlerfall statt zu einer unerwarteten Exception. Das folgende Beispiel erweitert den Wrapper um eine optionale timeoutMs-Option, deren Typ über ein erweitertes RequestInit-Interface sauber in die bestehende Signatur eingebettet wird, ohne die Aufrufer-Seite zu verkomplizieren.


interface TimeoutOptions extends RequestInit {
  timeoutMs?: number;
}

async function requestWithTimeout<T>(url: string, options: TimeoutOptions = {}): Promise<ApiResult<T>> {
  const { timeoutMs = 8000, ...init } = options;
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);

  try {
    const response = await fetch(url, { ...init, signal: controller.signal });

    if (!response.ok) {
      return { ok: false, status: response.status, error: response.statusText };
    }

    return { ok: true, data: (await response.json()) as T };
  } catch (err) {
    if (err instanceof DOMException && err.name === 'AbortError') {
      return { ok: false, status: 0, error: `Timeout after ${timeoutMs}ms` };
    }
    return { ok: false, status: 0, error: err instanceof Error ? err.message : 'Network error' };
  } finally {
    clearTimeout(timer);
  }
}

7. Praxisbeispiel: Ein typisierter API-Client für Produkte

In der Praxis lohnt sich ein dünner, ressourcenspezifischer API-Client, der den generischen Wrapper kapselt und projektspezifische Endpunkte wie Magentos REST-API unter /rest/V1/products abbildet. Eine Klasse mit Methoden wie getBySku(), list() und create() macht die verfügbaren Operationen für ein Team sofort sichtbar, ohne dass jeder Aufrufer die URL-Struktur oder Query-Parameter selbst zusammenbauen muss.

Der Client bleibt schlank, weil er selbst keine Fehlerbehandlung dupliziert, sondern die typisierte requestWithTimeout<T>-Funktion aus dem vorherigen Abschnitt wiederverwendet. So bleibt jede Methode auf ihre eigentliche Aufgabe fokussiert: URL und Payload korrekt zusammensetzen, den generischen Rückgabetyp festlegen und den Rest dem Wrapper überlassen. Für headless Frontends, die sowohl Magento- als auch andere REST-Backends ansprechen, lässt sich dasselbe Muster pro Ressource wiederholen.


interface ProductListParams {
  searchTerm?: string;
  pageSize?: number;
  currentPage?: number;
}

class ProductApiClient {
  constructor(private readonly baseUrl: string, private readonly token: string) {}

  private headers(): HeadersInit {
    return {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${this.token}`,
    };
  }

  async getBySku(sku: string): Promise<ApiResult<Product>> {
    return requestWithTimeout<Product>(`${this.baseUrl}/rest/V1/products/${sku}`, {
      headers: this.headers(),
    });
  }

  async list(params: ProductListParams): Promise<ApiResult<{ items: Product[]; total_count: number }>> {
    const query = new URLSearchParams();
    if (params.searchTerm) query.set('searchCriteria[search]', params.searchTerm);
    if (params.pageSize) query.set('searchCriteria[pageSize]', String(params.pageSize));

    return requestWithTimeout(`${this.baseUrl}/rest/V1/products?${query.toString()}`, {
      headers: this.headers(),
    });
  }

  async create(payload: Omit<Product, 'sku'> & { sku: string }): Promise<ApiResult<Product>> {
    return requestWithTimeout<Product>(`${this.baseUrl}/rest/V1/products`, {
      method: 'POST',
      headers: this.headers(),
      body: JSON.stringify({ product: payload }),
    });
  }
}

8. Compile-Time-Vertrauen vs. Runtime-Realität

Selbst der sorgfältigste Wrapper löst ein grundsätzliches Problem nicht: TypeScript prüft Typen ausschließlich zur Compile-Zeit. Der Typparameter T in request<Product>(url) ist eine Zusage des Entwicklers an sich selbst, keine Garantie über die tatsächliche Server-Antwort. Ändert sich ein Backend-Feld, fällt eine Migration aus, oder liefert ein Drittanbieter-API unerwartet null statt eines Strings, akzeptiert der kompilierte JavaScript-Code die Daten anstandslos, weil zur Laufzeit keine Typinformation mehr existiert.

Dieser Vertrauensvorschuss ist in kontrollierten internen APIs oft vertretbar, wird aber riskant bei extern kontrollierten Endpunkten, Legacy-Systemen oder APIs, die sich ohne Versionierung ändern können. Der nächste konsequente Schritt ist Runtime-Validierung: Bibliotheken wie Zod prüfen die tatsächliche Struktur der Antwort gegen ein Schema und leiten den TypeScript-Typ direkt daraus ab, sodass Compile-Zeit- und Laufzeit-Sicherheit erstmals wirklich übereinstimmen. Dieses Thema würde den Rahmen hier sprengen und verdient einen eigenen Artikel, aber die Weiche dorthin sollte jedes Team kennen, sobald der Wrapper aus diesem Artikel produktiv läuft.

9. Retry, Caching und der Vergleich: untyped vs. typed fetch

Zwei praktische Ergänzungen runden einen produktionsreifen Fetch-Wrapper ab: Retry-Logik für transiente Fehler und ein einfacher Cache für wiederholte Anfragen. Entscheidend ist, dass beide Mechanismen den generischen Typ T durchreichen, statt ihn beim Zwischenspeichern oder erneuten Versuch zu verlieren. Ein Map<string, unknown>-Cache mit einer Typ-Assertion beim Auslesen funktioniert, solange konsequent nur über den typisierten Wrapper geschrieben wird, nie direkt.

Bei Retries gilt die Faustregel, nur auf Netzwerkfehler und 5xx-Statuscodes erneut zu versuchen, niemals auf 4xx-Client-Fehler wie 400 oder 404, da eine Wiederholung an der Ursache nichts ändert und nur Latenz addiert. Ein exponentielles Backoff zwischen den Versuchen verhindert, dass ein bereits überlastetes Backend durch aggressive Retries zusätzlich belastet wird.


interface RetryOptions {
  retries?: number;
  backoffMs?: number;
}

const cache = new Map<string, unknown>();

async function cachedRequest<T>(url: string, options: RetryOptions = {}): Promise<ApiResult<T>> {
  if (cache.has(url)) {
    // The cached value keeps its original type T through the generic signature
    return { ok: true, data: cache.get(url) as T };
  }

  const { retries = 2, backoffMs = 300 } = options;

  for (let attempt = 0; attempt <= retries; attempt++) {
    const result = await requestWithTimeout<T>(url);

    if (result.ok) {
      cache.set(url, result.data);
      return result;
    }

    // Only retry on network errors and 5xx, never on 4xx client errors
    if (result.status !== 0 && result.status < 500) {
      return result;
    }

    if (attempt < retries) {
      await new Promise((resolve) => setTimeout(resolve, backoffMs * (attempt + 1)));
    }
  }

  return { ok: false, status: 0, error: 'Max retries exceeded' };
}

Die folgende Übersicht fasst zusammen, wie sich untypisierte fetch()-Aufrufe von einem konsequent typisierten Wrapper unterscheiden.

Aspekt Untyped fetch() Typisierter Wrapper
Response-Typ const data = await res.json() ist any const data = await request<Product>(url) ist Product
Fehlerbehandlung try/catch mit unbekanntem Fehlerformat ApiResult<T> als discriminated union
Timeout Kein Timeout, Request kann hängen AbortController mit typisiertem Timeout-Error
Request-Body JSON.stringify(obj) ohne Typprüfung body: TBody über generischen Parameter geprüft
Caching Cache-Map ohne Typinformation (any) Cache<T> über den Wrapper-Generic typisiert
Wiederverwendbarkeit fetch()-Aufrufe verstreut in Komponenten Zentrale Client-Klasse pro Ressource

In der Praxis zeigt sich der Unterschied selten in einem einzelnen dramatischen Bug, sondern in der Summe kleiner, vermeidbarer Fehler, die ein typisierter Wrapper von vornherein ausschließt. Teams, die konsequent auf request<T> statt auf rohes fetch() setzen, verschieben Fehler von der Produktion zurück in die IDE, wo sie am günstigsten zu beheben sind.

Mironsoft

TypeScript-Tooling und typsichere API-Integrationen für Magento- und Headless-Projekte

Typsichere API-Anbindung für euer Projekt?

Wir entwickeln typsichere Fetch-Wrapper, API-Clients und Build-Tooling für Magento-Headless-Setups und TypeScript-Frontends, von der Generic-Architektur bis zur Runtime-Validierung.

Fetch-Wrapper & API-Clients

Generische request<T>()-Funktionen mit typisierten Error-Results für eure REST- und GraphQL-Endpunkte

Runtime-Validierung

Schema-basierte Validierung mit Zod oder io-ts als nächster Schritt nach der reinen Typisierung

TypeScript-Tooling

Build-Skripte, CI-Checks und strikte tsconfig-Settings für stabile Headless-Frontends

10. Zusammenfassung

Die Fetch API typsicher wrappen löst ein Kernproblem: response.json() liefert immer any, und ein Generic-Parameter allein validiert zur Laufzeit nichts. Ein zentraler request<T>()-Wrapper mit einem Result-Type statt Exceptions, typisierten Request-Bodies und Headern sowie einem AbortController-basierten Timeout macht Fehlerfälle für den Compiler sichtbar und verhindert hängende Requests. Ein ressourcenspezifischer API-Client kapselt diesen Wrapper und macht die verfügbaren Operationen für ein Team sofort greifbar.

Retry- und Caching-Logik lassen sich generisch über <T> ergänzen, ohne die Typsicherheit zu verlieren, solange konsequent über den Wrapper statt direkt über fetch() gearbeitet wird. Wichtig bleibt das Bewusstsein für die Grenze zwischen Compile-Zeit und Laufzeit: Der nächste konsequente Schritt nach diesem Wrapper-Pattern ist Runtime-Validierung mit Bibliotheken wie Zod, die die tatsächliche Struktur der Serverantwort prüft, statt ihr blind zu vertrauen.

Fetch API typsicher wrappen - Das Wichtigste auf einen Blick

Response ist nie automatisch T

response.json() liefert any. Ein Generic ohne Wrapper validiert nichts zur Laufzeit.

Result-Type statt Exception

Discriminated union mit ok: true/ok: false macht Fehlerfälle für den Compiler sichtbar.

Timeout gehört dazu

AbortController mit typisiertem Timeout-Error verhindert hängende Requests.

Nächster Schritt: Runtime-Validierung

Zod & Co. schließen die Lücke zwischen Compile-Zeit und echten API-Daten.

11. FAQ: Die Fetch API typsicher wrappen

1Warum liefert fetch() keinen typsicheren Response-Body?
response.json() gibt immer Promise<any> zurück, weil fetch() das Antwortformat nicht kennt. TypeScript kann daraus ohne zusätzliche Maßnahmen keinen konkreten Typ ableiten.
2Validiert ein Generic wie request<Product>() die Daten zur Laufzeit?
Nein. Ein Typparameter existiert nur zur Compile-Zeit und verschwindet beim Kompilieren zu JavaScript. Er ist eine Behauptung an den Compiler, keine Prüfung der tatsächlichen Antwort.
3Was macht ein generischer Fetch-Wrapper besser als rohes fetch()?
Er bündelt Header, Statuscode-Prüfung und Typumwandlung an einer Stelle und macht den erwarteten Rückgabetyp an jedem Aufrufer sichtbar, statt fetch() über die Codebasis verstreut aufzurufen.
4Was ist ein Result- beziehungsweise Either-Type?
Eine discriminated union mit einem Feld wie ok: true oder ok: false, die Erfolg und Fehler explizit im Rückgabetyp abbildet und den Compiler zwingt, beide Fälle zu behandeln.
5Wie behandle ich HTTP-Statuscodes typsicher?
response.ok vor der JSON-Umwandlung prüfen und bei Fehlern ein strukturiertes Error-Objekt mit Status und Nachricht zurückgeben, statt eine unbehandelte Exception zu werfen.
6Wie typisiere ich Request-Bodies für POST- und PUT-Anfragen?
Mit einem generischen TBody-Parameter und Utility-Types wie Partial<T> für PATCH oder Omit<T, 'id'> für Create-Endpunkte, sodass falsch benannte Felder schon im Editor auffallen.
7Wie funktioniert ein typsicheres Timeout mit AbortController?
Ein AbortController erzeugt ein Signal, das an fetch() übergeben wird. Ein setTimeout() ruft bei Überschreitung abort() auf, und der Wrapper unterscheidet die resultierende DOMException sauber von echten Netzwerkfehlern.
8Reicht TypeScript allein aus, um API-Antworten abzusichern?
Nein. TypeScript prüft nur zur Compile-Zeit. Für echte Sicherheit gegenüber unerwarteten Serverdaten ist zusätzliche Runtime-Validierung mit Bibliotheken wie Zod nötig.
9Bleiben Typen bei Retry- und Caching-Logik erhalten?
Ja, wenn Cache und Retry-Funktionen selbst generisch über T implementiert werden und ausschließlich über den typisierten Wrapper schreiben und lesen, statt any zu verwenden.
10Eignet sich das Wrapper-Pattern auch für Magento-Headless-Projekte?
Ja. Ein ressourcenspezifischer API-Client, der den generischen Wrapper kapselt, bildet Magentos REST-API wie /rest/V1/products typsicher ab und lässt sich pro Ressource wiederholen.