Typing API Responses
Recipe
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.
Working Example
// 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>
);
}Deep Dive
How It Works
fetchreturnsResponse, andresponse.json()returnsPromise<any>. Theas Promise<T>cast tells TypeScript what shape to expect, but does not validate the data at runtime.- A generic fetch wrapper (
fetchJson<T>) centralizes error handling and typing. Every call site specifies the expected response shape. - For true runtime safety, pair your types with a validation library like Zod. This ensures the API actually returned the shape you expect.
- Separate your API types (what the server sends) from your domain types (what your app uses). Map between them in a service layer.
Variations
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" } };
}
}TypeScript Notes
response.json()returnsPromise<any>. The castas 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.- Use
unknowninstead ofanyfor unvalidated data. It forces you to narrow or validate before accessing properties.
Gotchas
- Casting with
as Tprovides zero runtime safety. If the API changes its response format, your code will fail silently until a property access crashes. fetchdoes not throw on 4xx or 5xx responses. You must checkresponse.okorresponse.statusmanually.- API types may not match your frontend domain types (e.g., dates come as strings, not
Dateobjects). Transform them in a mapping layer. - Forgetting to handle loading and error states leads to accessing
.dataonnull, causing runtime errors.
Alternatives
| 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 |
FAQs
Why does response.json() return Promise<any> and what should you do about it?
- JSON parsing is inherently untyped -- TypeScript cannot know what shape the data will be.
- Use
as Promise<T>to cast, or validate with Zod'sschema.parse(json)for runtime safety. - The cast provides zero runtime safety -- the API could return anything.
What is the benefit of a generic fetch wrapper like fetchJson<T>?
- Centralizes error handling (checking
response.ok, catching network errors). - Each call site specifies the expected response shape via the type parameter.
- Reduces duplicate fetch boilerplate across components.
How does Zod provide both compile-time and runtime type safety?
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 aZodErrorif the data does not match at runtime.
Gotcha: Does as T casting provide any runtime protection?
- No.
as Tis a compile-time-only assertion with zero runtime safety. - If the API changes its response format, your code will fail silently until a property access crashes.
- For critical API boundaries, use Zod or a custom type guard instead.
Why doesn't fetch throw on 4xx or 5xx responses?
fetchonly rejects on network failures (DNS errors, no connection).- HTTP error status codes (400, 404, 500) are considered successful network requests.
- You must check
response.okorresponse.statusmanually.
When should you use a custom type guard vs Zod validation?
- Custom type guards have no dependencies but are tedious and error-prone for complex types.
- Zod provides a single source of truth (schema = type + validation) but adds a dependency.
- Use Zod for API boundaries; use type guards for simple, one-off checks.
Why should API types be separate from domain types?
- APIs may send dates as strings, use snake_case, or include fields your app does not need.
- A mapping layer converts API types to domain types (e.g., parsing date strings to
Dateobjects). - This decouples your frontend from backend schema changes.
How do you type a discriminated union for API results (success vs error)?
type ApiResult<T> =
| { success: true; data: T }
| { success: false; error: ApiError };- The
successfield acts as the discriminant. - TypeScript narrows
dataanderroraccess based on thesuccesscheck.
Gotcha: What happens if you forget to handle loading and error states?
- Accessing
.dataon anullstate causes a runtime error. - Always model three states: loading, error, and success.
- Use a discriminated union or separate state variables for each.
What is the advantage of using unknown instead of any for unvalidated data?
unknownforces you to narrow or validate before accessing properties.anysilently allows all property access without checks.- Use
const data: unknown = await response.json()as the starting point for validation.