ESC

Type to search the knowledge base.

Typing fetch Responses

Type HTTP JSON safely — Response generics myths, unknown + schema parse, error unions, and React Query-friendly patterns.

intermediate3 min read
  • typescript
  • typing-fetch

fetch is untyped about your JSON shape. res.json() is essentially Promise<any> in DOM libs (or weakly typed). Pretending otherwise with a generic that doesn’t validate is a common production bug.

// False confidence
async function getUser(id: string): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  return res.json(); // not really User
}

Docs: MDN fetch, unknown.

Honest baseline

async function getUser(id: string): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  if (!res.ok) {
    throw new Error(`HTTP ${res.status}`);
  }
  const data: unknown = await res.json();
  return parseUser(data);
}

parseUser is a type guard or schema — runtime validation with schemas.

Result union instead of throws

type ApiError = { status: number; message: string };

type ApiResult<T> =
  | { ok: true; data: T }
  | { ok: false; error: ApiError };

async function getUserResult(id: string): Promise<ApiResult<User>> {
  try {
    const res = await fetch(`/api/users/${id}`);
    const data: unknown = await res.json().catch(() => null);
    if (!res.ok) {
      return {
        ok: false,
        error: {
          status: res.status,
          message: messageFrom(data) ?? res.statusText,
        },
      };
    }
    return { ok: true, data: parseUser(data) };
  } catch {
    return { ok: false, error: { status: 0, message: 'Network error' } };
  }
}

UI can switch on ok without try/catch at every call site — discriminated unions.

Don’t fake Response generics

Some wrappers do:

function get<T>(url: string): Promise<T> {
  return fetch(url).then((r) => r.json());
}

That’s an assertion factory. Prefer:

function getJson(url: string): Promise<unknown> {
  return fetch(url).then(async (r) => {
    if (!r.ok) throw new Error(String(r.status));
    return r.json();
  });
}

Then parse.

Headers, credentials, abort

async function load(url: string, signal?: AbortSignal): Promise<unknown> {
  const res = await fetch(url, {
    method: 'GET',
    headers: { Accept: 'application/json' },
    credentials: 'same-origin',
    signal,
  });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json();
}

Type the options you use; don’t over-abstract early.

Typing error payloads

const ErrorSchema = z.object({
  message: z.string(),
  code: z.string().optional(),
});

function messageFrom(data: unknown): string | undefined {
  const r = ErrorSchema.safeParse(data);
  return r.success ? r.data.message : undefined;
}

React Query / SWR

useQuery({
  queryKey: ['user', id],
  queryFn: () => getUser(id), // Promise<User>
});

The library’s data type flows from queryFn. Keep parse inside queryFn so data is real User | undefined.

Streaming / non-JSON

Not every response is JSON. Branch on Content-Type or known endpoints. Typing blob() / text() is straightforward — Promise<Blob> / Promise<string>.

Footguns

  1. Ignoring non-2xx — still calling .json() and treating as success.
  2. Assuming empty 204 has a body.
  3. Double parsing or reading body twice.
  4. Global any on a custom api.get<T>.

Interview out-loud answer

“I type fetch by validating JSON: unknown then schema/guard. I handle !res.ok separately, model results as unions when useful, and avoid generic get<T> that only asserts. Query libraries inherit whatever queryFn actually returns.”

Pagination helpers

type Page<T> = { items: T[]; nextCursor: string | null };
async function getPage(url: string): Promise<Page<User>> {
  const data: unknown = await getJson(url);
  return PageSchema.parse(data);
}

Keep pagination wrappers thin and always parse — cursor endpoints are frequent sources of backend drift.

Further reading

Related guides