A type-safe API client is one of the highest-leverage things you can build in a TypeScript app. Done well, every endpoint call autocompletes, every response is validated, every error has a known shape, and refactors across the codebase cost minutes instead of hours.
Done poorly, you get a generic fetch wrapper with as any sprinkled around. Let's build the good version.
Start With A Small Fetch Wrapper
The base layer is intentionally small — one function that handles the things every endpoint needs the same way: serialization, headers, base URL, JSON parsing.
type RequestOpts = {
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
body?: unknown;
headers?: Record<string, string>;
signal?: AbortSignal;
};
class HttpError extends Error {
constructor(
public status: number,
public body: unknown,
public requestId?: string,
) { super(`HTTP ${status}`); this.name = 'HttpError'; }
}
const BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? '';
export async function http(path: string, opts: RequestOpts = {}): Promise<unknown> {
const res = await fetch(`${BASE_URL}${path}`, {
method: opts.method ?? 'GET',
headers: {
'content-type': 'application/json',
...opts.headers,
},
body: opts.body ? JSON.stringify(opts.body) : undefined,
signal: opts.signal,
});
const requestId = res.headers.get('x-request-id') ?? undefined;
const body = res.status === 204 ? null : await res.json().catch(() => null);
if (!res.ok) throw new HttpError(res.status, body, requestId);
return body;
}
Notice the return type is Promise<unknown> — not Promise<T> with a generic. We don't trust the response yet. Type safety comes from the layer above.
Parse Responses At The Edge
Layer two: an endpoint definition that carries its own schema. Every call validates the response against the schema, so the typed return is also runtime-checked.
import { z, ZodSchema } from 'zod';
type Endpoint<I, O> = {
path: (input: I) => string;
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
input?: ZodSchema<I>; // request body schema (for POST/PATCH)
output: ZodSchema<O>; // response schema
};
export async function call<I, O>(
ep: Endpoint<I, O>,
input: I,
opts: { signal?: AbortSignal } = {},
): Promise<O> {
const body = await http(ep.path(input), {
method: ep.method,
body: ep.method && ep.method !== 'GET' ? input : undefined,
signal: opts.signal,
});
return ep.output.parse(body); // validates and returns typed
}
Now every endpoint is a small declarative object:
const UserSchema = z.object({
id: z.string(),
email: z.string().email(),
name: z.string(),
});
export const getUser: Endpoint<{ id: string }, z.infer<typeof UserSchema>> = {
path: ({ id }) => `/users/${id}`,
output: UserSchema,
};
const user = await call(getUser, { id: '42' });
// user is { id: string; email: string; name: string } — typed AND validated
One schema. Compile-time type. Runtime check. No drift between them, ever.
Return Results Instead Of Throwing Everywhere
Throwing is convenient until error handling becomes the dominant code in your hooks. A Result type makes errors first-class data:
type Result<T, E = HttpError> =
| { ok: true; data: T }
| { ok: false; error: E };
export async function safeCall<I, O>(
ep: Endpoint<I, O>,
input: I,
opts: { signal?: AbortSignal } = {},
): Promise<Result<O>> {
try {
const data = await call(ep, input, opts);
return { ok: true, data };
} catch (err) {
if (err instanceof HttpError) return { ok: false, error: err };
throw err; // rethrow non-HTTP errors (programmer bugs, etc.)
}
}
Consumers handle errors as data:
const result = await safeCall(getUser, { id: '42' });
if (!result.ok) return showError(result.error.status);
useUser(result.data);
This pattern is especially useful in React — error states become part of the discriminated union, not hidden in try/catch blocks scattered across components.
Add Retries Only Where Safe
Wrapping the client in retry logic is tempting. It's also dangerous if you retry the wrong things.
function isRetryable(err: HttpError): boolean {
if (err.status >= 500) return true; // server errors
if (err.status === 429) return true; // rate limit
// do NOT retry: 4xx (validation, auth, not found) — they don't fix themselves
return false;
}
export async function callWithRetry<I, O>(
ep: Endpoint<I, O>,
input: I,
{ tries = 3, baseDelayMs = 200 } = {},
): Promise<O> {
let lastErr: unknown;
for (let i = 0; i < tries; i++) {
try { return await call(ep, input); }
catch (err) {
lastErr = err;
if (!(err instanceof HttpError) || !isRetryable(err)) throw err;
await new Promise(r => setTimeout(r, baseDelayMs * 2 ** i));
}
}
throw lastErr;
}
Only opt specific endpoints into retry — usually idempotent reads. POST endpoints with side effects need idempotency keys before they're safe to retry.
Auth Should Be A Layer, Not A Parameter
Threading an auth token through every call is the wrong shape. Wrap the HTTP layer instead:
let getToken: () => string | null = () => null;
export const setAuthProvider = (fn: typeof getToken) => { getToken = fn; };
export async function authedHttp(path: string, opts: RequestOpts = {}): Promise<unknown> {
const token = getToken();
return http(path, {
...opts,
headers: {
...(token ? { authorization: `Bearer ${token}` } : {}),
...opts.headers,
},
});
}
Endpoint code stays clean. Auth is a cross-cutting concern handled in one place. Token rotation, refresh, and expiry can be added without touching every call site.
Pro Tips
- Co-locate schemas with endpoints. One file per resource: schema + endpoint definitions.
- Return
Resultfor predictable errors, throw for programmer bugs. - Pass
AbortSignalthrough every call. Cancellation is not optional. - Retry only retryable status codes. And only for idempotent endpoints.
- Test the boundary. Mock fetch, run the schema parsing, assert on
Resultshapes.
Final Tips
The point of a typed API client isn't fewer lines — it's fewer surprises. Renaming a backend field, the type updates, the schema fails on parse, the bug appears at compile time instead of in production logs.
Build the small wrapper. Co-locate the schemas. Use Result for the things that fail. Your hooks and pages get to focus on what they do, not on parsing JSON.
Good luck — and may your endpoints autocomplete forever 👊



