Typing fetch Responses
Type HTTP JSON safely — Response generics myths, unknown + schema parse, error unions, and React Query-friendly patterns.
- 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
}
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
- Ignoring non-2xx — still calling
.json()and treating as success. - Assuming empty 204 has a body.
- Double parsing or reading body twice.
- Global
anyon a customapi.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.
Related on this site
- Runtime validation with schemas
- unknown vs any
- Discriminated unions for UI state
- Contract testing APIs
- Mocking fetch in tests
Further reading
Related guides
- Basic Types and AnnotationsPrimitives, arrays, objects, and function annotations TypeScript actually checks — plus where inference is enough and where it is not.
- Branded Types for IDsNominal-style UserId vs OrderId in TypeScript — prevent ID mixups at compile time with brands, parsers, and form boundaries.
- Conditional Types IntroT extends U ? X : Y — how TypeScript picks types from conditions, distributes over unions, and powers utility types you already use.
- Declaration Files and DefinitelyTypedHow .d.ts files describe JS to TypeScript, when to use @types packages, module augmentation, and writing minimal ambient types for untyped libs.
- Discriminated Unions for UI StateModel loading, success, and error as mutually exclusive variants so TypeScript and your UI cannot show impossible states.