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, andResponsedefinitions insrc/lib/dom.generated.d.tsprovide the foundation for type-safe HTTP calls without extra dependencies. - Wrap for strong typing: Because
fetchreturnsPromise<Response>, use a generic wrapper function (typedFetch<T>) to cast the parsed body to your domain model. - Validate status codes: Always check
response.okbefore parsing the body; throw a customHttpErrorto unify error handling across your application. - Parse by Content-Type: Use the
Content-Typeheader to select betweenjson(),text(),blob(), orarrayBuffer(), or allow callers to specify the parser explicitly. - Handle network and timeout errors: Pass an
AbortSignalfromAbortControllertoRequestInit.signal, and wrap the call intry … catchto handleDOMExceptionaborts 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →