Lesson 80 of 80
A generic fetch wrapper lets you write apiGet<User>('/me') and get a typed User back — but the generic is a promise you're making to the compiler, not a check the compiler runs on the actual response.
15 min read
Every option explains itself — why the right one is right, and why each wrong one is wrong. Miss one and it comes back later on a spaced-repetition schedule.
This topic is about judgment and practical tradeoffs more than a single right answer — a mock interview is the better way to test yourself on it.
Be interviewed on this →Hints without spoilers, and a plain explanation of why a wrong answer was wrong — answered only from this lesson. Free account, no card.
Sign up freeWhat interviewers actually ask about this, and what a strong answer has to cover.
A strong answer covers
Likely follow-ups
Every frontend codebase ends up with some version of a typed fetch wrapper, because calling fetch directly and manually asserting the shape of the response at every call site gets old fast. The pattern is a generic function: you tell it what type you expect back, and it hands you a Promise of exactly that type, so the caller gets autocomplete and type-checking on the result without repeating an assertion every time.
The part that trips people up is what the generic is actually doing. apiGet<User>("/me") does not check, at runtime, that the response is shaped like User. TypeScript types don't exist at runtime — they're erased during compilation. The generic is you telling the compiler "trust me, this comes back as a User," and the compiler believes you unconditionally. If the API changes a field name, or an endpoint returns an error object instead, your code compiles fine and fails somewhere downstream, often confusingly, often far from the actual fetch call.
async function apiGet<T>(url: string): Promise<T> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Request to ${url} failed with status ${response.status}`);
}
return response.json() as T;
}
interface User {
id: string;
name: string;
email: string;
}
const user = await apiGet<User>("/api/me");
console.log(user.email.toUpperCase()); // typechecks fine
This compiles cleanly and user.email really does autocomplete as a string. But look at what response.json() actually returns: Promise<any>. The as T right after it is a type assertion — it doesn't transform or validate anything, it just tells the compiler to stop treating the value as any and start treating it as T. If the server actually returns { id: "1", fullName: "Adesh" } — no email field, a renamed name — TypeScript won't catch it. user.email.toUpperCase() throws Cannot read properties of undefined at runtime, and the stack trace points at the .toUpperCase() call, not at the real problem, which is that the response never matched T in the first place.
Closing that gap means validating the response at the one place it actually enters your type system — right where T gets asserted — using a runtime validator instead of a compile-time-only assertion:
import { z } from "zod";
const userSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string(),
});
async function apiGetValidated<T>(url: string, schema: { parse(data: unknown): T }): Promise<T> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Request to ${url} failed with status ${response.status}`);
}
const data: unknown = await response.json();
return schema.parse(data); // throws with a real error if the shape doesn't match
}
const user = await apiGetValidated("/api/me", userSchema);
Notice T isn't even passed explicitly here — it's inferred from schema's return type, so apiGetValidated can't be called with a schema and a mismatched type argument that disagree with each other. response.json() is typed as unknown, not asserted away, and schema.parse is the only thing allowed to turn that unknown into a User — and it actually walks the object checking every field. Now a renamed email field throws immediately, at the fetch call, with a message naming the actual missing field, instead of surfacing three components downstream as a mystery undefined.
as T reads like a check but is a promise. response.json() as T and response.json() as unknown as SomethingElse both compile no matter how wrong T is — assertions silence the compiler's uncertainty, they don't resolve it. The compiler was already going to give you any here; the assertion just picks a more specific lie.apiGet<TResponse, TParams, TQuery>(...) trying to type every angle of a request. All of that still rests on the same unchecked as T at the bottom — more generics tighten the shape you declare, not the data you actually received.zod schema without a matching TypeScript type still leaves call sites weakly typed if you don't use z.infer to derive one; a TypeScript type without a runtime schema looks safe and isn't. Deriving the type from the schema (type User = z.infer<typeof userSchema>) keeps them from drifting apart, since only one of them can be the source of truth.satisfies and assertions decide truthThis is the same gap between compile-time and runtime that shows up with type assertions generally — a generic fetch wrapper is really just as T wearing a friendlier signature, and the fix (a runtime validator as the actual gatekeeper into your type system) is the same instinct that separates a real type guard from a bare assertion anywhere else in the codebase.