Type-safe Fetch
Build a small typed fetch helper with useful API errors and runtime validation.
Overview
A small TypeScript wrapper around fetch that keeps successful data typed, turns failed responses into useful errors, and avoids repeating the same response checks in every request.
The problem
Calling fetch directly is simple, but it does not reject when the server returns a 4xx or 5xx response. It also leaves JSON parsing and response typing to every caller.
const response = await fetch("/api/profile");
const profile = await response.json();At this point, profile is not safely validated and an unsuccessful response can continue through the application as if it worked.
A reusable fetch helper
The helper accepts the expected response type as a generic and throws a dedicated error with the HTTP status and optional response body.
class ApiError extends Error {
readonly status: number;
readonly body: unknown;
constructor(status: number, body: unknown) {
super(`Request failed with status ${status}`);
this.name = "ApiError";
this.status = status;
this.body = body;
}
}
const request = async <ResponseData>(
input: RequestInfo | URL,
init?: RequestInit
): Promise<ResponseData> => {
const response = await fetch(input, init);
const body: unknown = await response.json().catch(() => null);
if (!response.ok) {
throw new ApiError(response.status, body);
}
return body as ResponseData;
};Using the helper
Callers describe the data they expect and keep the request itself focused on intent.
interface Profile {
id: string;
name: string;
role: string;
}
const getProfile = (signal?: AbortSignal) =>
request<Profile>("/api/profile", { signal });
const updateProfile = (name: string) =>
request<Profile>("/api/profile", {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ name }),
});A component or server action can then handle API failures separately from unexpected errors.
try {
const profile = await updateProfile("Noreakun");
return profile;
} catch (error) {
if (error instanceof ApiError && error.status === 422) {
throw new Error("The profile details are invalid.");
}
throw error;
}Runtime validation
The generic improves the developer experience, but it does not prove that an external response matches the type at runtime. For untrusted APIs, pass the result through a schema before returning it.
import { z } from "zod";
const profileSchema = z.object({
id: z.string(),
name: z.string(),
role: z.string(),
});
const getSafeProfile = async () => {
const data = await request<unknown>("/api/profile");
return profileSchema.parse(data);
};Tech stack
Notes
Keep this abstraction small. Authentication refreshes, retries, caching, and observability can be added later when the application actually needs them. A thin helper is easier to understand and replace than a large client built around imagined requirements.