How to buildOctober 12, 20267 min read
Ready-Made SolutionsStep 9 of 11

Recipe: a typed API client on fetch

In short

A typed API client is a thin wrapper over fetch that every request in the project goes through: it builds the URL from the base address, sets shared headers, limits the waiting time, checks the response with a Zod schema and turns any failure into a single ApiError class with parsed Problem Details. This recipe gives you a ready-made api-client.ts file of about 180 lines, an example API module and tests against a local HTTP server.

Code recipeLevel: IntermediateAPI RecipesPractice: ~20–40 min

After this you will be able to

  • plug in a single client on top of fetch with a base URL, shared headers and a per-attempt timeout
  • type responses with Zod schemas so that the type and the runtime check never diverge
  • parse application/problem+json errors (RFC 9457) into a single ApiError class and tell them apart from a cancelled request
  • retry on failure only idempotent requests and a POST with an idempotency key

Verified with

TypeScript 7.0.2 (strict)Zod 4.6Fetch API, AbortSignal.timeout / AbortSignal.anyNode.js 24 LTS (24.18)Vitest 5.0.3

Checked against docs: October 12, 2026

Contents
  1. 01The task: fetch calls scattered across the project
  2. 02The code: api-client.ts
  3. 03How to plug it in: API modules and error messages
  4. 04How to check it
  5. 05Limitations
  6. 06Related materials and sources

The task: fetch calls scattered across the project

In a growing project fetch is called from dozens of places, and each has its own bugs. MDN points out the main one: fetch() rejects only on network failures, while a 404 or 500 arrives as a normal Response — forget to check response.ok, and a server error reaches the UI as data. fetch has no timeout option of its own, and (await res.json()) as User is a promise to the compiler that nobody checks: if the backend renames a field, the code breaks far away from the request.

The base address is copied around the project

Solution in the client
baseUrl is set once, and the path is joined to it without losing /v1

The token and shared headers in every call

Solution in the client
headers is an object or a function called before every attempt

A forgotten response.ok

Solution in the client
Any non-2xx response becomes an ApiError with the status and Problem Details

A response type taken on trust

Solution in the client
The response is checked with a Zod schema, and the type is inferred from the same schema

A request hangs forever

Solution in the client
AbortSignal.timeout() per attempt and AbortSignal.any() for cancellation by the caller

A retry creates duplicates

Solution in the client
Only idempotent methods and POST/PATCH with an Idempotency-Key are retried
What the client takes care of

The client is deliberately thin: no cache, no state and no dependencies other than Zod. Caching and loading state belong to the UI data layer; where that layer lives is covered in the frontend architecture article.

The code: api-client.ts

bash
npm init -y
npm pkg set type=module
npm install zod@4
npm install -D typescript @types/node@24 vitest
Dependencies; the versions the recipe was checked with are listed in the header
tsconfig.json
{
  "compilerOptions": {
    "noEmit": true,
    "target": "esnext",
    "module": "nodenext",
    "allowImportingTsExtensions": true,
    "verbatimModuleSyntax": true,
    "erasableSyntaxOnly": true,
    "strict": true,
    "skipLibCheck": true,
    "types": ["node"]
  },
  "include": ["src", "test"]
}
Strict mode is required: the Zod documentation asks for strict: true
src/api-client.ts
import { z } from "zod";

// RFC 9457: a member of the wrong type is ignored, unknown members are extensions
const ProblemDetailsSchema = z.looseObject({
  type: z.string().catch("about:blank").default("about:blank"),
  title: z.string().optional().catch(undefined),
  status: z.number().int().optional().catch(undefined),
  detail: z.string().optional().catch(undefined),
  instance: z.string().optional().catch(undefined),
});
export type ProblemDetails = z.output<typeof ProblemDetailsSchema>;

export type ApiErrorKind = "http" | "timeout" | "network" | "invalid-response";

// One error class for everything that can go wrong with a call.
// Cancellation by the caller is not an ApiError: the AbortError is rethrown as is.
export class ApiError extends Error {
  readonly kind: ApiErrorKind;
  readonly method: string;
  readonly url: string;
  readonly status: number | undefined;
  readonly problem: ProblemDetails | undefined;

  constructor(
    message: string,
    details: { kind: ApiErrorKind; method: string; url: string; status?: number; problem?: ProblemDetails; cause?: unknown },
  ) {
    super(message, { cause: details.cause });
    this.name = "ApiError";
    this.kind = details.kind;
    this.method = details.method;
    this.url = details.url;
    this.status = details.status;
    this.problem = details.problem;
  }
}

type HeadersProvider = HeadersInit | (() => HeadersInit | Promise<HeadersInit>);
type Query = Record<string, string | number | boolean | undefined>;

export type ApiClientOptions = {
  baseUrl: string; // "https://api.example.com/v1"
  headers?: HeadersProvider; // a function is called before every attempt: fresh token
  timeoutMs?: number; // per attempt
  retries?: number; // extra attempts, only for idempotent requests
  retryDelayMs?: number;
  fetch?: typeof fetch;
};

export type RequestOptions<S extends z.ZodType> = {
  schema: S; // checks the response body and gives it a type
  query?: Query;
  body?: unknown; // sent as JSON
  headers?: HeadersInit;
  signal?: AbortSignal; // cancellation by the caller
  timeoutMs?: number;
  idempotencyKey?: string; // makes POST and PATCH retryable if the API supports it
};

const IDEMPOTENT_METHODS = new Set(["GET", "HEAD", "OPTIONS", "PUT", "DELETE"]);
const RETRYABLE_STATUS = new Set([408, 429, 500, 502, 503, 504]);

function buildUrl(baseUrl: string, path: string, query: Query = {}): URL {
  // new URL("users", "https://x/v1") drops "/v1": keep the base path with a trailing slash
  const url = new URL(path.replace(/^\/+/, ""), baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`);
  for (const [key, value] of Object.entries(query)) {
    if (value !== undefined) url.searchParams.set(key, String(value));
  }
  return url;
}

async function readProblem(res: Response): Promise<ProblemDetails | undefined> {
  if (!res.headers.get("content-type")?.includes("application/problem+json")) {
    await res.body?.cancel();
    return undefined;
  }
  const parsed = ProblemDetailsSchema.safeParse(await res.json().catch(() => null));
  return parsed.success ? parsed.data : undefined;
}

function sleep(ms: number, signal: AbortSignal | undefined): Promise<void> {
  return new Promise((resolve, reject) => {
    if (signal?.aborted) return reject(signal.reason);
    const timer = setTimeout(done, ms);
    function done() {
      signal?.removeEventListener("abort", onAbort);
      resolve();
    }
    function onAbort() {
      clearTimeout(timer);
      reject(signal?.reason);
    }
    signal?.addEventListener("abort", onAbort, { once: true });
  });
}

export function createApiClient(options: ApiClientOptions) {
  const { baseUrl, timeoutMs = 10_000, retries = 2, retryDelayMs = 200, fetch: doFetch = fetch } = options;

  async function attempt<S extends z.ZodType>(method: string, url: URL, opts: RequestOptions<S>): Promise<z.output<S>> {
    const shared = typeof options.headers === "function" ? await options.headers() : options.headers;
    const headers = new Headers(shared);
    new Headers(opts.headers).forEach((value, key) => headers.set(key, value));
    headers.set("Accept", "application/json, application/problem+json");
    if (opts.idempotencyKey) headers.set("Idempotency-Key", opts.idempotencyKey);
    if (opts.body !== undefined) headers.set("Content-Type", "application/json");

    const timeout = AbortSignal.timeout(opts.timeoutMs ?? timeoutMs);
    const signal = opts.signal ? AbortSignal.any([opts.signal, timeout]) : timeout;
    const where = { method, url: url.toString() };

    try {
      const res = await doFetch(url, {
        method,
        headers,
        body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
        signal,
      });
      if (!res.ok) {
        const problem = await readProblem(res);
        const reason = problem?.title ?? res.statusText;
        throw new ApiError(`${method} ${url.pathname} answered ${res.status}${reason ? `: ${reason}` : ""}`, {
          kind: "http", status: res.status, problem, ...where,
        });
      }
      const data: unknown = res.status === 204 ? undefined : await res.json();
      const parsed = opts.schema.safeParse(data);
      if (!parsed.success) {
        throw new ApiError(`${method} ${url.pathname}: unexpected response\n${z.prettifyError(parsed.error)}`, {
          kind: "invalid-response", status: res.status, cause: parsed.error, ...where,
        });
      }
      return parsed.data;
    } catch (error) {
      if (error instanceof ApiError) throw error;
      if (opts.signal?.aborted) throw opts.signal.reason; // the caller cancelled: not our failure
      if (timeout.aborted) {
        throw new ApiError(`${method} ${url.pathname}: no response in ${opts.timeoutMs ?? timeoutMs} ms`, {
          kind: "timeout", cause: error, ...where,
        });
      }
      if (error instanceof SyntaxError) {
        throw new ApiError(`${method} ${url.pathname}: response is not JSON`, { kind: "invalid-response", cause: error, ...where });
      }
      throw new ApiError(`${method} ${url.pathname}: network error`, { kind: "network", cause: error, ...where });
    }
  }

  async function request<S extends z.ZodType>(method: string, path: string, opts: RequestOptions<S>): Promise<z.output<S>> {
    const url = buildUrl(baseUrl, path, opts.query);
    // RFC 9110: repeat automatically only idempotent requests (or ones with an idempotency key)
    const maxAttempts = IDEMPOTENT_METHODS.has(method) || opts.idempotencyKey ? retries + 1 : 1;

    for (let n = 1; ; n++) {
      try {
        return await attempt(method, url, opts);
      } catch (error) {
        const transient =
          error instanceof ApiError &&
          (error.kind === "network" || error.kind === "timeout" || RETRYABLE_STATUS.has(error.status ?? 0));
        if (!transient || n >= maxAttempts) throw error;
        // Simplified pause; Retry-After, jitter and an overall deadline are in the external API lesson
        await sleep(retryDelayMs * 2 ** (n - 1), opts.signal);
      }
    }
  }

  return {
    get: <S extends z.ZodType>(path: string, opts: RequestOptions<S>) => request("GET", path, opts),
    post: <S extends z.ZodType>(path: string, opts: RequestOptions<S>) => request("POST", path, opts),
    put: <S extends z.ZodType>(path: string, opts: RequestOptions<S>) => request("PUT", path, opts),
    patch: <S extends z.ZodType>(path: string, opts: RequestOptions<S>) => request("PATCH", path, opts),
    delete: <S extends z.ZodType>(path: string, opts: RequestOptions<S>) => request("DELETE", path, opts),
  };
}

export type ApiClient = ReturnType<typeof createApiClient>;
The whole client: URL, headers, timeout, cancellation, Problem Details, response schema and retries
  • buildUrl: new URL("users", "https://api.example.com/v1") gives https://api.example.com/users — a relative path replaces the last segment of the base. So the base gets a trailing / and the path loses its leading one. Parameters set to undefined do not reach the query string.
  • Accept: application/json, application/problem+json is the same header as in the RFC 9457 example. The client parses an error only when the server answers with application/problem+json; the body of other errors is not read.
  • ProblemDetailsSchema follows RFC 9457: a member of the wrong type is ignored (.catch(undefined)), a missing type is treated as about:blank, and unknown members — extensions such as balance or errors — are kept thanks to z.looseObject.
  • The timeout is AbortSignal.timeout(), which aborts the request with a TimeoutError when it expires; AbortSignal.any() combines it with the caller's signal. The signal also covers reading the body: a stalled JSON transfer is interrupted too.
  • Cancellation by the caller is not an API error: the original AbortError is rethrown, without retries. The UI can tell «the user left the page» from «the server did not answer».
  • The response is checked with schema.safeParse; on a mismatch, an ApiError of kind invalid-response carries the readable z.prettifyError text — handy for logs.
  • Retries are simplified: an exponential pause without jitter. RFC 9110 allows automatic retries of idempotent methods, and of others only when the retry is known to be safe — which is what an idempotency key provides. Retry-After, jitter, an overall deadline and a circuit breaker are covered in «External APIs: timeouts, retries, rate limits and graceful degradation» — do not duplicate them here; if you need them, pass that wrapper in the fetch option.

How to plug it in: API modules and error messages

  1. Schemas next to the calls

    One module per resource with Zod schemas and functions: users.get, users.list. The type is inferred from the schema with z.output; no separate interface is needed.

  2. One client per API

    The composition point creates the client with the base URL from configuration and a token getter, and exposes typed modules.

  3. Components call modules

    The UI calls api.users.get(id, signal) and gets a User whose createdAt is a Date — the schema did the conversion.

  4. Errors become UI texts

    catch checks instanceof ApiError and picks a message by error kind, status and type. An AbortError is not shown to the user.

src/users.ts
import { z } from "zod";
import type { ApiClient } from "./api-client.ts";

// The schema is both the runtime check and the TypeScript type
export const User = z.object({
  id: z.string(),
  name: z.string(),
  email: z.email(),
  createdAt: z.iso.datetime().transform((value) => new Date(value)),
});
export type User = z.output<typeof User>;

const UserPage = z.object({
  items: z.array(User),
  nextCursor: z.string().nullable(),
});

export function makeUsersApi(api: ApiClient) {
  return {
    get: (id: string, signal?: AbortSignal) =>
      api.get(`users/${encodeURIComponent(id)}`, { schema: User, signal }),
    list: (cursor?: string) =>
      api.get("users", { schema: UserPage, query: { cursor, limit: 20 } }),
    // One key per operation: a retry of the same POST does not create a second user
    create: (input: { name: string; email: string }) =>
      api.post("users", { schema: User, body: input, idempotencyKey: crypto.randomUUID() }),
    remove: (id: string) =>
      api.delete(`users/${encodeURIComponent(id)}`, { schema: z.undefined() }), // 204 No Content
  };
}
A resource module: the schema is both the check and the type; POST gets an idempotency key
src/api.ts
import { createApiClient } from "./api-client.ts";
import { makeUsersApi } from "./users.ts";

// Composition point: one client per API, typed modules on top of it.
// Where the token comes from (session, refresh flow) is the app's decision.
export function createApi(baseUrl: string, getToken: () => Promise<string | null>) {
  const client = createApiClient({
    baseUrl,
    timeoutMs: 8_000,
    headers: async (): Promise<HeadersInit> => {
      const token = await getToken();
      return token ? { Authorization: `Bearer ${token}` } : {};
    },
  });
  return { users: makeUsersApi(client) };
}
Composition point: client, token and typed modules
src/error-message.ts
import type { ApiError } from "./api-client.ts";

// User texts come from your own translations, keyed by kind, status and the problem "type" URI.
// "detail" is for logs and debugging: do not parse it and do not show it as is.
export function errorMessage(error: ApiError): string {
  if (error.kind === "timeout" || error.kind === "network") {
    return "The server is not responding. Check the connection and try again.";
  }
  if (error.kind === "invalid-response" || (error.status ?? 500) >= 500) {
    return "Something went wrong on our side. We are already looking into it.";
  }
  if (error.status === 401) return "Your session has expired. Please sign in again.";
  if (error.problem?.type === "https://api.example.com/problems/email-taken") {
    return "This email is already registered.";
  }
  return "The request could not be completed.";
}
A user message by error kind, status and problem type

The client and the modules use only the standard fetch, URL, Headers and AbortSignal, so they work both in the browser and on the server. While checking the recipe, the src folder also passed tsc with the dom libraries and without Node.js types. In the browser CORS applies on top: requests to another domain have to be allowed by the server.

How to check it

The tests run against a real node:http server on a random port: the client goes through the whole network path, and each test sets its own server scenario. The server records incoming requests, which is how URLs, headers, bodies and the number of attempts are checked.

test/server.ts
import { createServer, type IncomingHttpHeaders, type IncomingMessage, type ServerResponse } from "node:http";
import type { AddressInfo } from "node:net";

export type Handler = (req: IncomingMessage, res: ServerResponse, n: number) => void;
export type Recorded = { method: string; url: string; headers: IncomingHttpHeaders; body: string };

// A real HTTP server on a random local port: the client goes through the network stack
export async function startServer() {
  let handler: Handler = (_req, res) => void res.writeHead(404).end();
  const requests: Recorded[] = [];
  const server = createServer(async (req, res) => {
    let body = "";
    for await (const chunk of req) body += chunk;
    requests.push({ method: req.method ?? "", url: req.url ?? "", headers: req.headers, body });
    handler(req, res, requests.length);
  });
  await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
  const { port } = server.address() as AddressInfo;

  return {
    baseUrl: `http://127.0.0.1:${port}/v1`,
    requests,
    handle(next: Handler) {
      handler = next;
      requests.length = 0;
    },
    close: () =>
      new Promise<void>((resolve) => {
        server.closeAllConnections();
        server.close(() => resolve());
      }),
  };
}

export function send(res: ServerResponse, status: number, data: unknown, type = "application/json") {
  if (res.destroyed) return; // the client has already given up
  res.writeHead(status, { "Content-Type": type }).end(typeof data === "string" ? data : JSON.stringify(data));
}
A local server for tests: the handler is set in the test, requests are recorded
test/api-client.test.ts
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { z } from "zod";
import { ApiError, createApiClient } from "../src/api-client.ts";
import { createApi } from "../src/api.ts";
import { errorMessage } from "../src/error-message.ts";
import { send, startServer } from "./server.ts";

let server: Awaited<ReturnType<typeof startServer>>;
beforeAll(async () => {
  server = await startServer();
});
afterAll(() => server.close());

const anna = { id: "u1", name: "Anna", email: "anna@example.com", createdAt: "2026-10-12T09:00:00Z" };
const api = () => createApi(server.baseUrl, async () => "test-token");
const fail = (promise: Promise<unknown>) => promise.then(() => expect.fail("should reject"), (error: unknown) => error);

describe("typed API client", () => {
  it("joins the base URL, sends headers and returns typed data", async () => {
    server.handle((_req, res) => send(res, 200, anna));
    const user = await api().users.get("u1");
    expect(user.createdAt).toBeInstanceOf(Date);
    expect(user.name).toBe("Anna");
    expect(server.requests[0]).toMatchObject({ method: "GET", url: "/v1/users/u1" });
    expect(server.requests[0]!.headers.authorization).toBe("Bearer test-token");
    expect(server.requests[0]!.headers.accept).toBe("application/json, application/problem+json");
  });

  it("puts query parameters into the URL and skips undefined ones", async () => {
    server.handle((_req, res) => send(res, 200, { items: [anna], nextCursor: null }));
    const page = await api().users.list();
    expect(page.items[0]!.email).toBe("anna@example.com");
    expect(server.requests[0]!.url).toBe("/v1/users?limit=20");
  });

  it("rejects a body that does not match the schema and does not retry it", async () => {
    server.handle((_req, res) => send(res, 200, { ...anna, email: "not-an-email" }));
    const error = await fail(api().users.get("u1"));
    expect(error).toBeInstanceOf(ApiError);
    expect(error).toMatchObject({ kind: "invalid-response", status: 200 });
    expect(server.requests).toHaveLength(1);
  });

  it("turns application/problem+json into ApiError and keeps extensions", async () => {
    const problem = {
      type: "https://example.com/probs/out-of-credit",
      title: "You do not have enough credit.",
      detail: "Your current balance is 30, but that costs 50.",
      instance: "/account/12345/msgs/abc",
      balance: 30,
      accounts: ["/account/12345", "/account/67890"],
    };
    server.handle((_req, res) => send(res, 403, problem, "application/problem+json"));
    const client = createApiClient({ baseUrl: server.baseUrl });
    const error = await fail(client.post("purchase", { schema: z.object({}), body: { item: 123456, quantity: 2 } }));
    expect(error).toBeInstanceOf(ApiError);
    expect(error).toMatchObject({ kind: "http", status: 403, problem });
    expect((error as ApiError).message).toBe("POST /v1/purchase answered 403: You do not have enough credit.");
    expect(server.requests[0]!.headers["content-type"]).toBe("application/json");
    expect(JSON.parse(server.requests[0]!.body)).toEqual({ item: 123456, quantity: 2 });
  });

  it("ignores problem members of the wrong type (RFC 9457, section 3.1)", async () => {
    server.handle((_req, res) => send(res, 404, { title: 42, status: "404", detail: "No user u9" }, "application/problem+json"));
    const error = (await fail(api().users.get("u9"))) as ApiError;
    expect(error.problem).toEqual({ type: "about:blank", detail: "No user u9" });
    expect(error.message).toBe("GET /v1/users/u9 answered 404: Not Found");
  });

  it("retries an idempotent GET after 503", async () => {
    server.handle((_req, res, n) => (n === 1 ? send(res, 503, "busy", "text/plain") : send(res, 200, anna)));
    expect((await api().users.get("u1")).id).toBe("u1");
    expect(server.requests).toHaveLength(2);
  });

  it("does not retry POST without an idempotency key", async () => {
    server.handle((_req, res) => send(res, 503, "busy", "text/plain"));
    const client = createApiClient({ baseUrl: server.baseUrl, retryDelayMs: 1 });
    const error = await fail(client.post("orders", { schema: z.object({}), body: {} }));
    expect(error).toMatchObject({ kind: "http", status: 503, problem: undefined });
    expect(server.requests).toHaveLength(1);
  });

  it("retries POST with an idempotency key and sends the same key every time", async () => {
    server.handle((_req, res, n) => (n === 1 ? send(res, 503, "busy", "text/plain") : send(res, 201, anna)));
    const user = await api().users.create({ name: "Anna", email: "anna@example.com" });
    expect(user.id).toBe("u1");
    const keys = server.requests.map((r) => r.headers["idempotency-key"]);
    expect(keys).toHaveLength(2);
    expect(keys[0]).toBeTruthy();
    expect(keys[1]).toBe(keys[0]);
  });

  it("returns undefined for 204 No Content", async () => {
    server.handle((_req, res) => void res.writeHead(204).end());
    await expect(api().users.remove("u1")).resolves.toBeUndefined();
  });

  it("gives up after timeoutMs with kind timeout", async () => {
    server.handle((_req, res) => setTimeout(() => send(res, 200, anna), 300));
    const client = createApiClient({ baseUrl: server.baseUrl, timeoutMs: 50, retries: 0 });
    const error = await fail(client.get("users/u1", { schema: z.unknown() }));
    expect(error).toMatchObject({ kind: "timeout" });
  });

  it("passes the caller's cancellation through as AbortError, without retries", async () => {
    server.handle((_req, res) => setTimeout(() => send(res, 200, anna), 300));
    for (const retries of [0, 2]) {
      const client = createApiClient({ baseUrl: server.baseUrl, retries });
      const controller = new AbortController();
      setTimeout(() => controller.abort(), 30);
      const error = await fail(client.get("users/u1", { schema: z.unknown(), signal: controller.signal }));
      expect(error).not.toBeInstanceOf(ApiError);
      expect((error as Error).name).toBe("AbortError");
    }
    expect(server.requests).toHaveLength(2); // one request per call
  });

  it("reports a non-JSON success body as invalid-response", async () => {
    server.handle((_req, res) => send(res, 200, "<html>maintenance</html>", "text/html"));
    const error = await fail(api().users.get("u1"));
    expect(error).toMatchObject({ kind: "invalid-response" });
  });

  it("maps a problem type to a user message", async () => {
    server.handle((_req, res) =>
      send(res, 409, { type: "https://api.example.com/problems/email-taken", title: "Email taken" }, "application/problem+json"),
    );
    const error = (await fail(api().users.create({ name: "Anna", email: "anna@example.com" }))) as ApiError;
    expect(errorMessage(error)).toBe("This email is already registered.");
  });

  it("reports a refused connection as a network error", async () => {
    const probe = createServer();
    await new Promise<void>((resolve) => probe.listen(0, "127.0.0.1", resolve));
    const { port } = probe.address() as AddressInfo;
    await new Promise<void>((resolve) => probe.close(() => resolve()));
    const client = createApiClient({ baseUrl: `http://127.0.0.1:${port}`, retries: 1, retryDelayMs: 1 });
    const error = await fail(client.get("health", { schema: z.unknown() }));
    expect(error).toMatchObject({ kind: "network" });
  });
});
14 tests: data, schema, Problem Details, retries, timeout, cancellation, network error
terminal
$ npx tsc -p .
$ npx vitest run --reporter=verbose

RUN  v5.0.3 …

 ✓ test/api-client.test.ts > typed API client > joins the base URL, sends headers and returns typed data 18ms
 ✓ test/api-client.test.ts > typed API client > puts query parameters into the URL and skips undefined ones 3ms
 ✓ test/api-client.test.ts > typed API client > rejects a body that does not match the schema and does not retry it 3ms
 ✓ test/api-client.test.ts > typed API client > turns application/problem+json into ApiError and keeps extensions 5ms
 ✓ test/api-client.test.ts > typed API client > ignores problem members of the wrong type (RFC 9457, section 3.1) 17ms
 ✓ test/api-client.test.ts > typed API client > retries an idempotent GET after 503 231ms
 ✓ test/api-client.test.ts > typed API client > does not retry POST without an idempotency key 16ms
 ✓ test/api-client.test.ts > typed API client > retries POST with an idempotency key and sends the same key every time 246ms
 ✓ test/api-client.test.ts > typed API client > returns undefined for 204 No Content 2ms
 ✓ test/api-client.test.ts > typed API client > gives up after timeoutMs with kind timeout 62ms
 ✓ test/api-client.test.ts > typed API client > passes the caller's cancellation through as AbortError, without retries 76ms
 ✓ test/api-client.test.ts > typed API client > reports a non-JSON success body as invalid-response 2ms
 ✓ test/api-client.test.ts > typed API client > maps a problem type to a user message 2ms
 ✓ test/api-client.test.ts > typed API client > reports a refused connection as a network error 13ms

 Test Files  1 passed (1)
      Tests  14 passed (14)
Real output: the type check passes, 14 tests (your timings will differ)

The tests were checked the other way round: one at a time we broke Problem Details parsing, limiting retries to idempotent methods, passing AbortError through, ignoring members of the wrong type, URL joining, the Idempotency-Key header and skipping undefined in the query. Each time at least one relevant test failed.

Limitations

  • The timeout is per attempt. With retries: 2 and timeoutMs: 8000, the worst case is three attempts of 8 seconds plus pauses. Add an overall deadline with an AbortSignal.timeout() in the call's signal or with the wrapper from the external API lesson
  • The request body is JSON only. FormData, file uploads and streamed responses need a separate method: the browser or runtime sets the Content-Type for them
  • Only a 204 response is treated as empty. If the API returns 200 without a body, it ends up as invalid-response — agree on this with the backend or extend the condition
  • Schema checks cost time on large lists. If a response with thousands of records arrives often, check it selectively or only in development — as a deliberate decision, not by default
  • Zod adds code to the frontend bundle. For size-critical pages there is Zod Mini — the same functionality with a functional API that tree-shakes better
  • If the API has an OpenAPI contract, it is more convenient to generate schemas and types from it and use this recipe as the transport layer

Related materials and sources

  • «External APIs: timeouts, retries, rate limits and graceful degradation» — full retries: jitter, Retry-After, an overall deadline, a circuit breaker
  • «API design: resources, versioning, errors, pagination» — how the server should return Problem Details and support Idempotency-Key
  • «Frontend architecture: project structure, layers and boundaries» — where API code lives in a project
  • «Asynchronous JavaScript: promises, async/await and fetch» — the basics of fetch, AbortController and AbortSignal.timeout

Statements about fetch, AbortSignal, Zod and the RFCs were checked against the official documentation on the date in the header; the links are below.

How this was verified2 · passedchecked 12 October 2026

“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.