Working with REST s in often starts the same way: fetch, JSON.parse, and an as any cast that everyone agrees to forget about. It works until it doesn’t — and when it doesn’t, you’re debugging a Cannot read properties of undefined error in production.
Here’s the pattern I’ve settled on after too many of those nights.
The Core Wrapper
Instead of scattering fetch calls across components, I use a thin wrapper that encodes success and failure at the type level:
type ApiResult<T> =
| { ok: true; data: T }
| { ok: false; error: string; status: number };
async function apiFetch<T>(
path: string,
init?: RequestInit,
): Promise<ApiResult<T>> {
try {
const res = await fetch(`/api${path}`, init);
if (!res.ok) {
return { ok: false, error: res.statusText, status: res.status };
}
const data: T = await res.json();
return { ok: true, data };
} catch (err) {
return { ok: false, error: String(err), status: 0 };
}
}
The caller is forced to check ok before touching data. TypeScript enforces this via the discriminated union — you physically cannot access data on the error branch.
Usage
const result = await apiFetch<User>('/users/42');
if (!result.ok) {
console.error(result.error); // only available on the error branch
return;
}
console.log(result.data.name); // TypeScript knows data is User here
Runtime Validation with Zod
Types disappear at runtime. Pair the wrapper with to validate at the boundary:
import { z } from 'zod';
const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
});
type User = z.infer<typeof UserSchema>;
async function getUser(id: string): Promise<ApiResult<User>> {
const result = await apiFetch<unknown>(`/users/${id}`);
if (!result.ok) return result;
const parsed = UserSchema.safeParse(result.data);
if (!parsed.success) {
return { ok: false, error: 'Unexpected response shape', status: 0 };
}
return { ok: true, data: parsed.data };
}
The schema acts as a contract: if the API changes its response shape, your build won’t fail — but your tests will, which is the next best thing.