Tutorials
Building a Type-Safe API Layer in React with TypeScript
Stop sprinkling `any` across your fetch calls. Here's the small, boring pattern I use on every production app to keep server data honest.

Edwine Nyakundi
Full stack engineer · Sep 12, 2026 · 9 min read
Every React codebase I've inherited has the same soft spot: the boundary where network data enters the app. Types are asserted, not verified, and the first schema change from the backend turns into a runtime crash in production.
Validate at the boundary
The fix is unglamorous. Parse the response with a schema, once, at the edge. Everything downstream can then trust its types because they were earned, not declared.
import { z } from class="tok-string">"zod";
const User = z.object({
id: z.string().uuid(),
name: z.string(),
email: z.string().email(),
createdAt: z.coerce.date(),
});
export type User = z.infer<typeof User>;
export async function getUser(id: string): Promise<User> {
const res = await fetch(class="tok-string">`/api/users/${id}`);
if (!res.ok) throw new Error(class="tok-string">`Request failed: ${res.status}`);
return User.parse(await res.json());
}One client, many resources
Wrap the repetitive parts — base URL, headers, error shape — in a tiny helper. Not a framework, just a function you can read in ten seconds.
async function request<T>(path: string, schema: z.ZodType<T>, init?: RequestInit) {
const res = await fetch(path, {
...init,
headers: { class="tok-string">"content-type": class="tok-string">"application/json", ...init?.headers },
});
if (!res.ok) {
const body = await res.text();
throw new ApiError(res.status, body);
}
return schema.parse(await res.json());
}Where this pays off
- Backend renames a field — you get one loud error in one place instead of ten silent undefineds.
- Dates arrive as strings but leave as Date objects, so no more `new Date(x)` scattered through components.
- Your query hooks infer their return types for free.
Types you didn't verify are just comments with better syntax highlighting.
It costs maybe twenty extra lines per resource. I've never regretted writing them.
Related posts
Handling Large File Uploads with Node.js Streams
Buffering a 2GB upload into memory is a great way to meet your on-call rotation. Streams fix it in about thirty lines.
Jul 19, 2026 · 8 min read
Why Your React App Re-renders (And How to Actually Find Out)
Memoizing everything is not a performance strategy. Here's how I profile a slow React page and fix the real cause.
Jun 11, 2026 · 10 min read
Four Postgres Indexes I Wish I'd Understood Earlier
A 2.4s query became 11ms after one index. Here's how I reason about B-tree, partial, composite, and GIN indexes now.
Aug 28, 2026 · 11 min read
Newsletter
Get new posts in your inbox
Deep dives on TypeScript, Postgres, and engineering careers — roughly twice a month. No courses to sell, no spam.