Most teams call an endpoint the same way every time: fetch, res.json(), then read fields and hope. It works until the response shifts. A typed client turns that ad-hoc call into a small, named function with types, validation, and a mock — so the endpoint behaves like a proper module instead of a string you pass around.
The path has five steps, and each one earns its place.
1. Types from the response
Start with a real payload and derive the interface:
export interface User {
id: number;
email: string;
role: "admin" | "editor" | "viewer";
lastLogin: string | null;
}2. A Zod schema
Types describe the shape; the schema enforces it at runtime. Deriving the type from the schema keeps them in lockstep:
import { z } from "zod";
export const UserSchema = z.object({
id: z.number().int(),
email: z.string().email(),
role: z.enum(["admin", "editor", "viewer"]),
lastLogin: z.string().datetime().nullable(),
});
export type User = z.infer<typeof UserSchema>;3. A client that validates
The client function wraps fetch, parses with the schema, and returns a typed value. Validation lives here, at the boundary:
export async function getUser(id: number): Promise<User> {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(`getUser failed: ${res.status}`);
return UserSchema.parse(await res.json());
}Every caller now gets a User that has actually been checked — not a hopeful cast.
4. A mock for tests
A mock that satisfies the same schema lets tests run without the network and guarantees your fixture stays valid as the schema evolves:
export const mockUser: User = {
id: 1,
email: "test@example.com",
role: "admin",
lastLogin: null,
};5. An example call
Finally, a one-line usage example documents the happy path for the next person: const user = await getUser(1042);. Small, but it saves a trip to the source.
When to use it
Build a typed client for any endpoint you call from more than one place, or where the response shape genuinely matters to the UI. For a one-off script or a throwaway prototype, it's overkill — a plain fetch is fine. The investment pays off when the same data flows through several components and you want one place to change when the API does.
Common mistakes
- Returning
res.json()untyped. The value isany, andanypoisons every line it touches. - Hand-duplicating the shape across files. Types in one file, schema in another, mock in a third — three things to update, two of which you'll forget.
- No mock. If tests hit the real network, they're slow and flaky and break when the backend is down.
- Swallowing validation errors. Catching the parse error and returning
nullhides the contract break you wanted to catch.
Generating these five layers by hand for every endpoint is the tedious part. The PayloadIQ Typed Client produces the types, schema, validating client, and mock together from one pasted response, so you can review and adjust rather than type boilerplate.