//
Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Type your API responses end-to-end, from fetch calls to component rendering. Use type guards and runtime validation to bridge the gap between untyped network data and your TypeScript types.
// Define your API types
type ApiUser = {
id: number;
name: string;
email: string;
role: "admin" | "editor" | "viewer";
};
type ApiResponse<T> = {
data: T;
meta: {
page: number;
totalPages: number;
totalCount: number;
};
};
// Type-safe fetch wrapper
async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response.json() as Promise<T>;
}
// Usage in a component
function UserList() {
const [result, setResult] = useState<ApiResponse<ApiUser[]> | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchJson<ApiResponse<ApiUser[]>>("/api/users")
.then(setResult)
.catch((err) => setError(err.message));
}, []);
if (error) return <p>Error: {error}</p>;
if (!result) return <p>Loading...</p>;
return (
<ul>
{result.data.map((user) => (
<li key={user.id}>{user.name} ({user.role})</li>
))}
</ul>
);
}fetch returns Response, and response.json() returns Promise<any>. The as Promise<T> cast tells TypeScript what shape to expect, but does not validate the data at runtime.fetchJson<T>) centralizes error handling and typing. Every call site specifies the expected response shape.Zod runtime validation:
import { z } from "zod";
const ApiUserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
role: z.enum(["admin", "editor", "viewer"]),
});
type ApiUser = z.infer<typeof ApiUserSchema>;
const ApiResponseSchema = <T extends z.ZodType>(dataSchema: T) =>
z.object({
data: dataSchema,
meta: z.object({
page: z.number(),
totalPages: z.number(),
totalCount: z.number(),
}),
});
async function fetchValidated<T>(url: string, schema: z.ZodType<T>): Promise<T> {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const json = await response.json();
return schema.parse(json); // Throws ZodError if validation fails
}
// Usage
const result = await fetchValidated(
"/api/users",
ApiResponseSchema(z.array(ApiUserSchema))
);Custom type guard:
function isApiUser(value: unknown): value is ApiUser {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
"name" in value &&
"email" in value &&
"role" in value &&
typeof (value as ApiUser).id === "number" &&
typeof (value as ApiUser).name === "string"
);
}
// Usage
const data: unknown = await response.json();
if (isApiUser(data)) {
console.log(data.name); // TypeScript knows data is ApiUser
}Error response typing:
type ApiError = {
message: string;
code: string;
details?: Record<string, string[]>;
};
type ApiResult<T> =
| { success: true; data: T }
| { success: false; error: ApiError };
async function fetchApi<T>(url: string): Promise<ApiResult<T>> {
try {
const response = await fetch(url);
const json = await response.json();
if (!response.ok) {
return { success: false, error: json as ApiError };
}
return { success: true, data: json as T };
} catch {
return { success: false, error: { message: "Network error", code: "NETWORK" } };
}
}response.json() returns Promise<any>. The cast as Promise<T> is a necessary compromise since JSON parsing is inherently untyped.z.infer<typeof Schema> derives the TypeScript type from a Zod schema, giving you a single source of truth.unknown instead of any for unvalidated data. It forces you to narrow or validate before accessing properties.as T provides zero runtime safety. If the API changes its response format, your code will fail silently until a property access crashes.fetch does not throw on 4xx or 5xx responses. You must check response.ok or response.status manually.Date objects). Transform them in a mapping layer..data on null, causing runtime errors.| Approach | Pros | Cons |
|---|---|---|
as T cast | Simple, zero dependencies | No runtime validation |
| Zod validation | Runtime + compile-time safety, single source of truth | Added dependency, parsing overhead |
Type guards (is functions) | No dependencies, explicit narrowing | Tedious for complex types, easy to get wrong |
| tRPC | End-to-end type safety, no manual typing | Requires server + client setup |
| GraphQL codegen | Types generated from schema | Build step, GraphQL ecosystem required |
as Promise<T> to cast, or validate with Zod's schema.parse(json) for runtime safety.response.ok, catching network errors).const UserSchema = z.object({
id: z.number(),
name: z.string(),
});
type User = z.infer<typeof UserSchema>; // derived type
const data = UserSchema.parse(json); // runtime validationz.infer<typeof Schema> derives the TypeScript type from the schema.schema.parse(json) throws a ZodError if the data does not match at runtime.as T is a compile-time-only assertion with zero runtime safety.fetch only rejects on network failures (DNS errors, no connection).response.ok or response.status manually.Date objects).type ApiResult<T> =
| { success: true; data: T }
| { success: false; error: ApiError };success field acts as the discriminant.data and error access based on the success check..data on a null state causes a runtime error.unknown forces you to narrow or validate before accessing properties.any silently allows all property access without checks.const data: unknown = await response.json() as the starting point for validation.Reviewed by Chris St. John·Last updated Jul 10, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥