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.

TypeScript src/api/http.ts
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.

TypeScript src/api/endpoint.ts
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:

TypeScript src/api/users.ts
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.

Generic apiClient blueprint. The signature on top declares an Endpoint with TBody and TRes type parameters and matching Zod schemas. The Define block on the left declares a typed createOrder endpoint. The Call block on the right shows result.data fully typed as Order on success and result.error typed as ApiError on failure.
One generic. Every endpoint typed in, typed out, typed errors.

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:

TypeScript src/api/result.ts
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:

TypeScript
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.

TypeScript
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:

TypeScript src/api/authed-http.ts
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

  1. Co-locate schemas with endpoints. One file per resource: schema + endpoint definitions.
  2. Return Result for predictable errors, throw for programmer bugs.
  3. Pass AbortSignal through every call. Cancellation is not optional.
  4. Retry only retryable status codes. And only for idempotent endpoints.
  5. Test the boundary. Mock fetch, run the schema parsing, assert on Result shapes.

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 👊