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.

8 min read

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.

typescripttypescript
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:

typescripttypescript
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.

typescripttypescript
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:

typescripttypescript
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:

typescripttypescript
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:

typescripttypescript
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.

typescripttypescript
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.

Typed fetch wrapper with result union

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.

typescripttypescript
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

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.
RunePowered by Rune AI

Frequently Asked Questions

Why build a fetch wrapper instead of using fetch directly?

A typed wrapper centralizes error handling, response parsing, base URL configuration, and type constraints. Instead of repeating try/catch and .json() calls everywhere, you write the logic once and get type-safe responses automatically.

Can I use this pattern with other HTTP libraries like axios?

Yes. The generic typing approach works with any HTTP library. The key idea is parameterizing the response type so that callers get the correct type back from every request without manual type assertions.

How do I handle different response status codes in a typed way?

Throw typed errors for each status code range or return a discriminated union like { ok: true; data: T } | { ok: false; error: string }. This forces callers to check success before accessing the data, which the compiler enforces.

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.