Typed API Responses
Network responses are the boundary where your carefully typed program meets data you do not control. The native fetch API returns res.json() as any, which silently disables type checking for everything downstream — a dangerous default. The correct approach is to treat incoming data as unknown and validate it before trusting any types. This page builds a reusable typed fetch wrapper, shows how to narrow unknown with hand-written type guards, and explains when to reach for a schema library like Zod for runtime validation that produces static types for free.
Why res.json() returns any
The DOM lib types Response.json() as Promise<any>. Assigning any to a typed variable compiles without complaint, so a typo in the API or a schema change ships straight to production undetected.
const res = await fetch("/api/user/1");
const user = await res.json(); // user: any -- no safety at all
console.log(user.naem.toUpperCase()); // compiles fine, crashes at runtime
The any type means user.naem (a typo) is not flagged. Because the data crosses a trust boundary, the type system cannot help unless you assert or validate the shape explicitly.
A generic fetch wrapper
A small generic wrapper centralizes the response check and lets each call site declare its expected type. It still returns the claimed type, so pair it with validation when the source is untrusted.
async function apiGet<T>(url: string): Promise<T> {
const res = await fetch(url);
if (!res.ok) {
throw new Error(`HTTP ${res.status} for ${url}`);
}
return res.json() as Promise<T>;
}
interface User {
id: number;
name: string;
email: string;
}
const user = await apiGet<User>("/api/user/1"); // user: User
The <T> type parameter flows through to the resolved value, so user is typed as User and res.json()’s any is contained to one line. The res.ok guard converts HTTP error statuses into thrown errors. The cast as Promise<T> is a promise to the compiler — it does not verify the data, which is why validation matters next.
A cast like
as Tonly tells the compiler what to assume; it performs zero runtime checks. Treat casts on network data as a temporary convenience, not a guarantee.
Validating unknown with type guards
The safe pattern returns unknown and forces validation through a user-defined type guard (value is T). The guard inspects the data at runtime; only inside the truthy branch does TypeScript treat it as the narrowed type.
function isUser(value: unknown): value is User {
return (
typeof value === "object" &&
value !== null &&
"id" in value && typeof (value as Record<string, unknown>).id === "number" &&
"name" in value && typeof (value as Record<string, unknown>).name === "string"
);
}
async function getUser(id: number): Promise<User> {
const res = await fetch(`/api/user/${id}`);
const data: unknown = await res.json();
if (!isUser(data)) {
throw new Error("Invalid user payload");
}
return data; // narrowed to User
}
Starting from unknown makes the compiler demand a check before you access properties. The isUser guard performs real runtime inspection, and its value is User return type tells TypeScript that a true result means the value really is a User. Hand-written guards are fine for a few small shapes but grow tedious and error-prone for large, nested payloads.
Validating with Zod
For anything beyond trivial shapes, a schema library such as Zod (or Valibot/ArkType) defines the shape once and gives you both runtime validation and a static type via inference. This keeps the runtime and compile-time descriptions in sync automatically.
import { z } from "zod";
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
});
type User = z.infer<typeof UserSchema>; // { id: number; name: string; email: string }
async function getUser(id: number): Promise<User> {
const res = await fetch(`/api/user/${id}`);
const data: unknown = await res.json();
return UserSchema.parse(data); // throws ZodError if invalid, returns typed User
}
UserSchema.parse(data) validates at runtime and returns a value typed as User; z.infer derives the static type from the schema so you never duplicate the shape. Use safeParse when you prefer a result object over a thrown error.
Choosing a validation strategy
| Approach | Runtime safety | Maintenance | Best for |
|---|---|---|---|
as T cast | none | low | prototypes, fully trusted sources |
| Hand-written type guard | full | high for big shapes | one or two small payloads |
| Schema library (Zod) | full | low | real apps, nested/large payloads |
Best Practices
- Treat every network payload as
unknownand validate before using it. - Never trust
res.json()’sany— cast only as a temporary measure, prefer validation. - Centralize fetch logic in a generic
apiGet<T>wrapper that checksres.ok. - Use user-defined type guards (
value is T) for small, stable shapes. - Reach for Zod or a similar schema library for large or nested responses, and derive types with
z.infer. - Keep the runtime schema and the static type as a single source of truth.
- Convert non-OK HTTP statuses into thrown errors so callers handle failure explicitly.