home
Code

Type-safe Fetch

Build a small typed fetch helper with useful API errors and runtime validation.

Type-safe Fetch

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.

profile-fetch.ts
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

Fetch API/
Zod/

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.