A generic request wrapper instead of an any response
Using fetch() directly gives TypeScript no guarantee about the shape of the response: response.json() returns any, and a plain generic parameter validates nothing at runtime. This article shows how a generic fetch wrapper with typed error results, timeout handling, and a practical API client brings real type safety to Magento and headless projects.
Table of Contents
- 1. The problem: fetch() has no type-safe response body
- 2. Why a generic parameter alone validates nothing
- 3. Building a generic fetch wrapper: request<T>()
- 4. Handling HTTP errors: typed error results instead of exceptions
- 5. Typing request payloads, bodies, and headers
- 6. Adding a type-safe timeout with AbortController
- 7. Practical example: a typed API client for products
- 8. Compile-time trust vs. runtime reality
- 9. Retry, caching, and the comparison: untyped vs. typed fetch
- 10. Summary
- 11. FAQ
1. The problem: fetch() has no type-safe response body
The native fetch() function is deliberately generic: it knows nothing about the expected response format or the structure of the data a server returns. Response.json() therefore always returns Promise<any>, whether the API delivers products, customer addresses, or an error message. In small scripts that barely matters, but in a growing Magento or headless frontend with dozens of endpoints, every any spot becomes a blind spot where the compiler stops catching mistakes.
What makes this particularly treacherous: a typo in a property name, a changed API version, or an empty response body doesn't cause a compile error. It surfaces only at runtime as undefined or an exception, often deep inside a component that has no idea the original request even happened. Anyone using TypeScript specifically to catch such mistakes early loses that benefit right at the network boundary if fetch() is left unhandled.
2. Why a generic parameter alone validates nothing
An obvious first step is to attach a type parameter to the call, for example fetch(url).then(r => r.json()) as Product or a wrapper function with <T>. The problem: a generic disappears completely at runtime. TypeScript compiles down to JavaScript, and JavaScript no longer knows anything about types. The expression as T is a pure type assertion, a claim made to the compiler: "Trust me, this is a T." The compiler never checks that claim against the actual data, it simply accepts it.
That creates a dangerous illusion of safety: the code looks type-safe, IntelliSense suggests the right properties, and yet the application still crashes the moment the API returns an unexpected shape. Pure TypeScript cannot close this gap on its own; it requires either disciplined wrapper design that models error cases explicitly, or genuine runtime validation, which this article touches on further down. The first step, though, is simply understanding exactly where the boundary between compile time and runtime lies.
3. Building a generic fetch wrapper: request<T>()
The pragmatic middle ground is a central fetch wrapper that routes every request through a single typed function instead of scattering fetch() calls across the codebase. A function like request<T>(url, options): Promise<T> bundles standard behavior such as headers, status code checks, and error handling in one place, and makes the expected return type explicit at every call site instead of leaving it to be guessed from context.
It's important to build the wrapper so error checking happens before the type conversion: response.ok must be checked before response.json() is called, otherwise a server error message ends up incorrectly flowing through the typed success path. The example below shows the basic structure, which the following sections extend with error types, timeout, and retry logic.
// 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. Handling HTTP errors: typed error results instead of exceptions
Exceptions are invisible to the TypeScript compiler: a function signature like Promise<Product> doesn't reveal that it can also throw on a 404 or 500. As a result, calling code regularly forgets to use try/catch, and the error propagates uncontrolled up the call stack. A more robust pattern comes from functional languages: a Result or Either type that explicitly models success and failure as a discriminated union in the return type.
With a discriminating field such as ok: true or ok: false, TypeScript can automatically narrow the type after a simple if check: in the success branch, data of type T is available; in the error branch, status and error are. Callers are thereby forced by the compiler to handle both cases, instead of silently ignoring errors or being surprised at runtime by an unhandled exception.
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. Typing request payloads, bodies, and headers
Type safety doesn't just apply to the response, it applies equally to the request body. A wrapper function that accepts a generic TBody for POST and PUT requests and internally calls JSON.stringify() prevents a misnamed field from only surfacing as a 400 error on the server. Utility types such as Partial<T> for PATCH requests or Omit<Product, 'sku'> for create endpoints, where the server assigns the ID itself, are particularly useful here.
Headers benefit from typing as well: a HeadersInit object with clearly named constants for Authorization, Content-Type, or project-specific headers like X-Store-Code prevents typos that would otherwise only show up in the network tab. In Magento headless setups with bearer token authentication, a small buildHeaders() helper is worth having: it centralizes token handling and decouples the wrapper from auth details, keeping it reusable across every resource.
6. Adding a type-safe timeout with AbortController
Without an explicit timeout, a fetch() call can hang indefinitely in the worst case, for example when a backend stops responding under load. The AbortController API solves this natively: an AbortController produces an AbortSignal that gets passed to fetch(), and a setTimeout() calls controller.abort() once the limit is exceeded. What matters for typing: an aborted request throws a DOMException with name === 'AbortError', which can be cleanly distinguished from genuine network errors.
Combining the timeout pattern with the Result type from the previous section turns a timeout into a regular, typed error case instead of an unexpected exception. The example below extends the wrapper with an optional timeoutMs option, whose type is cleanly embedded into the existing signature through an extended RequestInit interface, without complicating the calling side.
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. Practical example: a typed API client for products
In practice, a thin, resource-specific API client is worth building: it wraps the generic wrapper and maps project-specific endpoints such as Magento's REST API under /rest/V1/products. A class with methods like getBySku(), list(), and create() makes the available operations immediately visible to a team, without every caller having to assemble the URL structure or query parameters itself.
The client stays lean because it doesn't duplicate error handling; it reuses the typed requestWithTimeout<T> function from the previous section. That keeps every method focused on its actual job: assembling the correct URL and payload, declaring the generic return type, and leaving the rest to the wrapper. For headless frontends that talk to both Magento and other REST backends, the same pattern can be repeated per resource.
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 trust vs. runtime reality
Even the most careful wrapper doesn't solve one fundamental problem: TypeScript checks types exclusively at compile time. The type parameter T in request<Product>(url) is a promise a developer makes to themselves, not a guarantee about the actual server response. If a backend field changes, a migration fails to run, or a third-party API unexpectedly returns null instead of a string, the compiled JavaScript code accepts the data without complaint, because no type information exists anymore at runtime.
That trust is often reasonable for tightly controlled internal APIs, but it becomes risky for externally controlled endpoints, legacy systems, or APIs that can change without versioning. The next logical step is runtime validation: libraries such as Zod check the actual structure of the response against a schema and derive the TypeScript type directly from it, so compile-time and runtime safety finally match up for real. That topic goes beyond the scope of this article and deserves one of its own, but every team should know that this path exists once the wrapper from this article goes into production.
9. Retry, caching, and the comparison: untyped vs. typed fetch
Two practical additions round out a production-ready fetch wrapper: retry logic for transient failures and a simple cache for repeated requests. What matters is that both mechanisms carry the generic type T through, instead of losing it while caching or retrying. A Map<string, unknown> cache with a type assertion on read works fine, as long as it's consistently written only through the typed wrapper, never directly.
For retries, the rule of thumb is to only retry on network errors and 5xx status codes, never on 4xx client errors such as 400 or 404, since retrying changes nothing about the root cause and only adds latency. Exponential backoff between attempts prevents an already-overloaded backend from being hit even harder by aggressive retries.
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' };
}
The table below summarizes how untyped fetch() calls differ from a consistently typed wrapper.
| Aspect | Untyped fetch() | Typed wrapper |
|---|---|---|
| Response type | const data = await res.json() is any | const data = await request<Product>(url) is Product |
| Error handling | try/catch with an unknown error shape | ApiResult<T> as a discriminated union |
| Timeout | No timeout, request can hang | AbortController with a typed timeout error |
| Request body | JSON.stringify(obj) with no type checking | body: TBody checked via the generic parameter |
| Caching | Cache map with no type information (any) | Cache<T> typed through the wrapper generic |
| Reusability | fetch() calls scattered across components | Central client class per resource |
In practice, the difference rarely shows up as a single dramatic bug, but as the sum of small, avoidable mistakes that a typed wrapper rules out from the start. Teams that consistently use request<T> instead of raw fetch() move errors back from production into the IDE, where they're cheapest to fix.
Mironsoft
TypeScript tooling and type-safe API integrations for Magento and headless projects
Ready for type-safe API integration?
We build type-safe fetch wrappers, API clients, and build tooling for Magento headless setups and TypeScript frontends, from the generic architecture all the way to runtime validation.
Fetch wrappers & API clients
Generic request<T>() functions with typed error results for your REST and GraphQL endpoints
Runtime validation
Schema-based validation with Zod or io-ts as the next step after pure typing
TypeScript tooling
Build scripts, CI checks, and strict tsconfig settings for stable headless frontends
10. Summary
Type-safely wrapping the Fetch API addresses one core problem: response.json() always returns any, and a generic parameter alone validates nothing at runtime. A central request<T>() wrapper with a Result type instead of exceptions, typed request bodies and headers, and an AbortController-based timeout makes error cases visible to the compiler and prevents hanging requests. A resource-specific API client wraps this and makes the available operations immediately graspable for a team.
Retry and caching logic can be added generically over <T> without losing type safety, as long as everything consistently goes through the wrapper instead of calling fetch() directly. What still matters is staying aware of the boundary between compile time and runtime: the logical next step after this wrapper pattern is runtime validation with libraries such as Zod, which check the actual structure of the server response instead of trusting it blindly.
Type-Safely Wrapping the Fetch API - The Essentials at a Glance
A response is never automatically T
response.json() returns any. A generic without a wrapper validates nothing at runtime.
Result type instead of exception
A discriminated union with ok: true/ok: false makes error cases visible to the compiler.
Timeout is part of the deal
AbortController with a typed timeout error prevents hanging requests.
Next step: runtime validation
Zod and similar libraries close the gap between compile time and real API data.