Type-Safely Wrapping the Fetch API
AI generated
<T>
type
TypeScript · Fetch API · Generics · API Client
Type-Safely Wrapping the Fetch API
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.

12 min. read Generics · Result Types · AbortController TypeScript 5 · Fetch API · REST

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.

11. FAQ: Type-Safely Wrapping the Fetch API

1Why doesn't fetch() have a type-safe response body?
response.json() always returns Promise<any>, because fetch() has no knowledge of the response format. TypeScript cannot derive a concrete type from that without additional measures.
2Does a generic like request<Product>() validate the data at runtime?
No. A type parameter only exists at compile time and disappears once the code compiles to JavaScript. It is a claim made to the compiler, not a check against the actual response.
3What does a generic fetch wrapper do better than raw fetch()?
It bundles headers, status code checks, and type conversion in one place and makes the expected return type visible at every call site, instead of scattering fetch() calls across the codebase.
4What is a Result or Either type?
A discriminated union with a field such as ok: true or ok: false that explicitly models success and failure in the return type and forces the compiler to require handling of both cases.
5How do I handle HTTP status codes in a type-safe way?
Check response.ok before converting to JSON, and return a structured error object with status and message on failure, instead of throwing an unhandled exception.
6How do I type request bodies for POST and PUT requests?
With a generic TBody parameter and utility types such as Partial<T> for PATCH or Omit<T, 'id'> for create endpoints, so misnamed fields are caught in the editor rather than at runtime.
7How does a type-safe timeout with AbortController work?
An AbortController produces a signal that gets passed to fetch(). A setTimeout() calls abort() once the limit is exceeded, and the wrapper cleanly distinguishes the resulting DOMException from genuine network errors.
8Is TypeScript alone enough to secure API responses?
No. TypeScript only checks types at compile time. Real protection against unexpected server data requires additional runtime validation with libraries such as Zod.
9Do types survive retry and caching logic?
Yes, if the cache and retry functions themselves are implemented generically over T and only ever read from and write to the typed wrapper, instead of using any.
10Does the wrapper pattern also work for Magento headless projects?
Yes. A resource-specific API client that wraps the generic wrapper maps Magento's REST API, such as /rest/V1/products, in a type-safe way, and the same pattern can be repeated per resource.