TypeScript Fetch Requests: Best Practices for Response Types and Error Handling

Wrap the native fetch API in a generic TypeScript function that validates response.ok, handles multiple body parsers based on Content-Type, and throws custom HttpError instances for centralized error handling.

The Microsoft TypeScript repository ships with comprehensive type definitions for the browser Fetch API in its standard library, making it possible to build strongly-typed HTTP clients without external dependencies. When implementing typescript fetch requests, developers must address the gap between the generic Promise<Response> return type and the specific data shapes their applications expect. This guide demonstrates how to leverage the ambient DOM typings—found in src/lib/dom.generated.d.ts—to create a reusable, type-safe wrapper that handles JSON, text, binary responses, and HTTP error scenarios.

Understand the Native Fetch Types in TypeScript

Before wrapping fetch, you must understand how the TypeScript compiler defines the API surface. The declarations are located in the generated DOM library file.

The fetch Function Signature

According to src/lib/dom.generated.d.ts at line 43354, the global fetch function is declared as:

declare function fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;

This signature reveals that fetch always returns a Promise<Response>, regardless of whether the server returns JSON, HTML, or binary data. TypeScript cannot infer the specific payload type, which necessitates a generic wrapper.

RequestInit and Response Interfaces

The optional second argument conforms to the RequestInit interface defined at line 2206 in the same file. It includes properties such as method, headers, body, mode, credentials, and signal for cancellation.

The Response interface (line 29934) extends Body and provides:

  • Status checks: ok (boolean), status (number), statusText (string)
  • Header access: headers (Headers object)
  • Body consumption methods: json(), text(), blob(), arrayBuffer(), formData()

These methods return promises that resolve to distinct types, requiring careful selection based on the expected Content-Type.

Create a Type-Safe Fetch Wrapper

A robust wrapper bridges the gap between the generic Response and your application's domain models. The implementation below follows the five core practices: typed requests, status validation, content negotiation, network error handling, and cancellation support.

Handling Multiple Response Types

Different endpoints return different payloads. Your wrapper should accept a generic type parameter T and select the correct parser based on either an explicit argument or the Content-Type header.

/** Helper to infer response parsers from a MIME type */
function getParser<T>(type: string, resp: Response): Promise<T> {
  if (type.includes('application/json')) return resp.json() as Promise<T>;
  if (type.includes('text/')) return resp.text() as unknown as Promise<T>;
  if (type.includes('application/octet-stream')) return resp.arrayBuffer() as unknown as Promise<T>;
  // Fallback – raw Response object
  return Promise.resolve(resp as unknown as T);
}

Implementing Custom HTTP Error Classes

When response.ok is false (status outside 200-299), you should throw a structured error containing the status code, URL, and attempted body parse. This centralizes error handling for downstream callers.

/** Custom error to represent HTTP‑level failures */
export class HttpError extends Error {
  constructor(
    public readonly status: number,
    public readonly statusText: string,
    public readonly url: string,
    public readonly body: any,
  ) {
    super(`HTTP ${status} – ${statusText}`);
    this.name = 'HttpError';
  }
}

Adding Timeout and Cancellation Support

The RequestInit interface accepts an optional signal property. By creating an AbortController and linking it to a setTimeout, you achieve automatic request cancellation after a deadline.

const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15_000); // 15 seconds
const mergedInit = { ...(init ?? {}), signal: controller.signal };

Complete Implementation Example

The following utility combines all best practices into a single, reusable typedFetch function. It works with the standard DOM library definitions found in microsoft/TypeScript and requires no external dependencies.

export async function typedFetch<T = any>(
  input: RequestInfo | URL,
  init?: RequestInit,
  parser?: 'json' | 'text' | 'blob' | 'arrayBuffer',
): Promise<T> {
  // 1. Abort support with default timeout
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), 15_000);
  const mergedInit = { ...(init ?? {}), signal: controller.signal };

  try {
    const resp = await fetch(input, mergedInit);
    clearTimeout(timeout);

    // 2. HTTP status validation
    if (!resp.ok) {
      let errBody: any = undefined;
      try {
        const ct = resp.headers.get('Content-Type') ?? '';
        errBody = await getParser<any>(ct, resp);
      } catch {
        // ignore parsing errors
      }
      throw new HttpError(resp.status, resp.statusText, String(input), errBody);
    }

    // 3. Select parser
    if (parser) {
      switch (parser) {
        case 'json': return (await resp.json()) as T;
        case 'text': return (await resp.text()) as unknown as T;
        case 'blob': return (await resp.blob()) as unknown as T;
        case 'arrayBuffer': return (await resp.arrayBuffer()) as unknown as T;
      }
    }

    // 4. Auto-detect from Content-Type
    const ct = resp.headers.get('Content-Type') ?? '';
    return await getParser<T>(ct, resp);
  } catch (e) {
    // 5. Network-level and abort handling
    if (e instanceof DOMException && e.name === 'AbortError') {
      throw new Error('Request timed out or was aborted');
    }
    throw e;
  }
}

// Usage examples
interface User {
  id: number;
  name: string;
  email: string;
}

// GET JSON
async function loadUser(id: number): Promise<User> {
  return await typedFetch<User>(`https://api.example.com/users/${id}`, {
    method: 'GET',
    headers: { Accept: 'application/json' },
  });
}

// POST with body
async function createUser(payload: Partial<User>): Promise<User> {
  return await typedFetch<User>('https://api.example.com/users', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Accept: 'application/json',
    },
    body: JSON.stringify(payload),
  }, 'json');
}

Summary

  • Leverage ambient DOM types: The fetch, RequestInit, and Response definitions in src/lib/dom.generated.d.ts provide the foundation for type-safe HTTP calls without extra dependencies.
  • Wrap for strong typing: Because fetch returns Promise<Response>, use a generic wrapper function (typedFetch<T>) to cast the parsed body to your domain model.
  • Validate status codes: Always check response.ok before parsing the body; throw a custom HttpError to unify error handling across your application.
  • Parse by Content-Type: Use the Content-Type header to select between json(), text(), blob(), or arrayBuffer(), or allow callers to specify the parser explicitly.
  • Handle network and timeout errors: Pass an AbortSignal from AbortController to RequestInit.signal, and wrap the call in try … catch to handle DOMException aborts and network failures.

Frequently Asked Questions

How do I type the JSON response body when using fetch in TypeScript?

Because the native fetch function returns Promise<Response> according to src/lib/dom.generated.d.ts, you must explicitly cast the parsed result. Call response.json() and cast the result to your interface, or use a generic wrapper function like typedFetch<T>(url, init) that returns Promise<T>. This ensures that downstream code receives a strongly-typed object rather than any.

What is the best way to handle HTTP error status codes in TypeScript fetch requests?

Check the ok property on the Response object, which is false for any status outside the 200–299 range. When !response.ok, throw a custom error class (e.g., HttpError) that captures status, statusText, and the attempted body parse. This centralizes error handling and allows calling code to catch a single, predictable error type rather than checking status codes manually at every call site.

How can I implement request timeouts with fetch in TypeScript?

Create an AbortController instance and pass its signal property to the RequestInit object under the signal key. Then use setTimeout to call controller.abort() after your desired duration. According to the RequestInit interface in src/lib/dom.generated.d.ts, the signal property is fully typed as AbortSignal | null. When aborted, fetch rejects with a DOMException named AbortError, which you can catch and re-throw as a timeout-specific error.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →