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.
Inhaltsverzeichnis
- 1. Das Problem: fetch() liefert keinen typsicheren Response-Body
- 2. Warum ein Generic allein nichts validiert
- 3. Einen generischen Fetch-Wrapper bauen: request<T>()
- 4. HTTP-Fehler behandeln: typisierte Error-Results statt Exceptions
- 5. Request-Payloads, Bodies und Headers typisieren
- 6. Timeout und AbortController typsicher einbinden
- 7. Praxisbeispiel: Ein typisierter API-Client für Produkte
- 8. Compile-Time-Vertrauen vs. Runtime-Realität
- 9. Retry, Caching und der Vergleich: untyped vs. typed fetch
- 10. Zusammenfassung
- 11. FAQ
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.