Build a Typed Fetch Wrapper
Build a generic fetch wrapper in TypeScript that adds type-safe request and response handling. Learn to type URL parameters, request bodies, and parsed JSON responses.
A typed fetch wrapper is a function that wraps the browser's fetch API with TypeScript generics so every request returns a strongly typed response. Instead of calling fetch directly and casting the result with type assertions everywhere, you build the type safety into one reusable function.
The core idea: you tell the wrapper what shape of data you expect, and it handles the HTTP mechanics while giving you back a typed promise.
Starting with a Basic Wrapper
The simplest typed wrapper accepts a URL and a response type parameter. It calls fetch, checks the response status, parses the JSON, and returns the typed result.
async function apiGet<T>(url: string): Promise<T> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`);
}
return response.json() as T;
}The generic type T is the response body type. This single function replaces raw fetch calls throughout your codebase. When you call apiGet, you specify T, and TypeScript checks every property access against it.
Here is how callers use it with an interface:
interface User {
id: number;
name: string;
email: string;
}
const user = await apiGet<User>("/api/users/1");
console.log(user.name.toUpperCase());TypeScript now knows user has id, name, and email. Calling user.name.toUpperCase compiles because name is a string. If you try to access a property that does not exist, the compiler catches it before any code runs.
Adding Request Configuration
A real wrapper needs HTTP methods, request bodies, and headers. Define a typed options interface and extend the wrapper to accept it.
interface RequestConfig<Body = undefined> {
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
body?: Body;
headers?: Record<string, string>;
}The Body generic defaults to undefined so callers only specify it when sending a request body. The method field is constrained to valid HTTP methods, preventing typos like "GETT" at compile time.
Now plug this config into the wrapper. A small helper turns the config into fetch options:
function buildFetchOptions<Body>(config?: RequestConfig<Body>): RequestInit {
const { method = "GET", body, headers = {} } = config ?? {};
const fetchOptions: RequestInit = {
method,
headers: { "Content-Type": "application/json", ...headers },
};
if (body !== undefined) {
fetchOptions.body = JSON.stringify(body);
}
return fetchOptions;
}The helper destructures the config with defaults, so a missing method falls back to GET and the body is only stringified when present. The wrapper itself calls this helper, then runs the same fetch and error-checking steps as apiGet:
async function apiRequest<T, Body = undefined>(
url: string,
config?: RequestConfig<Body>
): Promise<T> {
const response = await fetch(url, buildFetchOptions(config));
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`);
}
return response.json() as T;
}TypeScript checks that the body matches the type you specify. If you declare a CreateUserBody interface and pass a body with a misspelled property, the compiler reports the error immediately. Here is a typed POST call:
interface CreateUserBody {
name: string;
email: string;
}
const newUser = await apiRequest<User, CreateUserBody>("/api/users", {
method: "POST",
body: { name: "Alex", email: "alex@example.com" },
});The compiler verifies every field in the body object against the interface before the request is sent.
Handling Errors with a Discriminated Result
A wrapper that throws is simple, but sometimes callers should handle errors explicitly. Return a discriminated union that forces them to check the outcome.
type ApiResult<T> =
| { ok: true; data: T }
| { ok: false; error: string; status: number };The union has two variants: success with data and failure with error details. Callers narrow on the ok field to access the correct shape.
The wrapper returns a union, and the caller must narrow it with an if statement. The compiler guarantees data is only accessible after checking ok, preventing access to missing response data.
Adding Query Parameter Support
Query parameters are a common source of runtime bugs from misspelled keys. Add typed parameter support with a simple URL builder.
function buildUrl(base: string, params?: Record<string, string | number>): string {
if (!params) return base;
const searchParams = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
searchParams.set(key, String(value));
}
return `${base}?${searchParams.toString()}`;
}The function takes a typed record and produces a properly encoded URL. TypeScript ensures param values are strings or numbers, matching what URL query strings support. Integrate this into the wrapper so that callers pass a typed params object and the serialization happens automatically.
Common Mistakes
Using any for the response type. Calling the wrapper with any defeats the purpose. Define an interface for every expected response shape, even if it starts with one field.
Forgetting that json() returns a promise of any. The type assertion inside the wrapper is necessary because json() has no type information. Keeping this assertion in one place is much safer than spreading it across every API call in your codebase.
Not handling non-JSON responses. If an endpoint returns plain text or an empty body, calling json() throws. Add a content-type check or a responseType option if your API includes non-JSON endpoints. For more on error handling patterns, see handle API errors in TypeScript.
For typing the response data models in detail, see type API response data in TypeScript.
Rune AI
Key Insights
- Parameterize your fetch wrapper with a generic type for the response body.
- Parse response.json() as the generic type inside the wrapper.
- Throw on non-ok responses for a clean error path.
- Accept typed request options including method, body, and headers.
- Use Awaited with ReturnType to extract the exact return type of the wrapper.
Frequently Asked Questions
Why build a fetch wrapper instead of using fetch directly?
Can I use this pattern with other HTTP libraries like axios?
How do I handle different response status codes in a typed way?
Conclusion
A typed fetch wrapper takes the repetitive parts of HTTP requests, such as base URLs, JSON parsing, and error handling, and wraps them in a generic function parameterized by the response type. Callers specify what shape they expect, and the wrapper returns a strongly typed promise. This eliminates manual type assertions spread across your codebase and makes API interactions consistent and safe.
More in this topic
TypeScript Compiler Explained
The TypeScript compiler (tsc) turns .ts files into .js files while checking types. Learn what it does, how to use it, and how it fits your workflow.
Set Up ESLint for TypeScript
Install and configure ESLint with typescript-eslint to catch bugs and enforce consistent code style in your TypeScript project.
Pick Utility Type in TypeScript
Pick creates a new type by selecting only the properties you name from an existing type. Use it to build focused types for specific views, API responses, or function parameters.