- Author
- app-hacks
- Published
TypeScript / Fetch API / API
A Type-Safe Fetch Wrapper for External APIs in TypeScript
Design a TypeScript fetch wrapper that distinguishes HTTP errors, invalid JSON, and response-shape mismatches while performing runtime validation.
Adding a generic type to fetch() does not prove that an external response has that shape. TypeScript types disappear after compilation, so runtime validation is required at the network boundary. A shared wrapper should keep transport failures, HTTP errors, parsing failures, and schema mismatches distinct.
Structure errors
export class HttpError extends Error {
constructor(
public readonly status: number,
public readonly statusText: string,
public readonly body: unknown,
public readonly bodyParseError?: unknown,
) {
super(`HTTP ${status}: ${statusText}`);
this.name = 'HttpError';
}
}
export class ResponseParseError extends Error {
constructor(public readonly cause: unknown) {
super('Response body could not be parsed');
this.name = 'ResponseParseError';
}
}
export class ValidationError extends Error {
constructor(message = 'Response validation failed') {
super(message);
this.name = 'ValidationError';
}
}
fetch does not reject for 404 or 500; it returns a Response whose ok is false. DNS failures, refused connections, and aborts do reject. Collapsing these cases prevents callers from making informed retry and UI decisions.
Accept a runtime validator
type Validator<T> = (value: unknown) => value is T;
type FetchJsonOptions<T> = RequestInit & { validate: Validator<T> };
async function readBody(response: Response): Promise<unknown> {
const contentType = response.headers.get('content-type') ?? '';
return contentType.includes('application/json') ? response.json() : response.text();
}
export async function fetchJson<T>(
input: RequestInfo | URL,
{ validate, headers, ...init }: FetchJsonOptions<T>,
): Promise<T> {
const requestHeaders = new Headers(headers);
if (!requestHeaders.has('Accept')) requestHeaders.set('Accept', 'application/json');
const response = await fetch(input, {
...init,
headers: requestHeaders,
});
if (!response.ok) {
let body: unknown = undefined;
let bodyParseError: unknown = undefined;
try { body = await readBody(response); }
catch (cause: unknown) { bodyParseError = cause; }
throw new HttpError(response.status, response.statusText, body, bodyParseError);
}
let body: unknown;
try { body = await readBody(response); }
catch (cause: unknown) { throw new ResponseParseError(cause); }
if (!validate(body)) throw new ValidationError();
return body;
}
Keep the body as unknown until validation succeeds. await response.json() as T only instructs TypeScript to trust an unchecked value. Read the stream once; after response.json() consumes it, the wrapper cannot read response.text() again for error diagnostics. Constructing Headers preserves every HeaderInit form, including a Headers instance, and retains a caller-supplied Accept header.
Write type guards
type User = { id: number; name: string; active: boolean };
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function isUser(value: unknown): value is User {
return isRecord(value) && typeof value.id === 'number' &&
typeof value.name === 'string' && typeof value.active === 'boolean';
}
function isUsers(value: unknown): value is User[] {
return Array.isArray(value) && value.every(isUser);
}
const controller = new AbortController();
const users = await fetchJson('https://api.example.com/users', {
validate: isUsers,
signal: AbortSignal.any([controller.signal, AbortSignal.timeout(8_000)]),
});
// Call controller.abort() for a user cancellation.
The generic connects caller code to the return type; the guard checks the external value. For larger models, a schema library such as Zod can replace handwritten guards, but only successfully parsed data should leave the wrapper.
Classify errors at the call site
try {
const users = await fetchJson('/api/users', { validate: isUsers });
renderUsers(users);
} catch (error: unknown) {
if (error instanceof HttpError) showHttpMessage(error.status);
else if (error instanceof ResponseParseError || error instanceof ValidationError) showMaintenanceMessage();
else if (error instanceof DOMException && error.name === 'TimeoutError') showTimeoutMessage();
else if (error instanceof DOMException && error.name === 'AbortError') {
// Do not normally show an error for a user cancellation.
}
else showNetworkMessage();
}
AbortSignal.timeout() aborts with a TimeoutError, while AbortController.abort() without an explicit reason uses AbortError. If an application supplies its own abort reason, establish an application-level convention around signal.reason instead of depending only on exception names. Returning an empty array for every failure makes “no data” indistinguishable from “request failed.” Keep HttpError.body for controlled diagnostics, but do not expose potentially sensitive upstream error bodies directly to users. Retry limits, backoff, cancellation, toast messages, and navigation belong above the transport wrapper. Its responsibility ends when a Response becomes validated application data.