EXPLAINER

TypeScript Types for an API Client

Define TypeScript types for the request body, success response and error response of each API call, then implement a client function that returns a typed result and handles non-2xx cases.

Short answer: For a typed API client: one type for the request, one for success, one for errors, plus a function that parses JSON safely. That is what "TypeScript types for an API client" means in practice.

What is it?

An API client is the module your app uses to call HTTP endpoints. In TypeScript you describe three shapes per endpoint: what you send, what success looks like, and what failure looks like. The compiler then checks every call site. This applies to your own /api/chat route and to vendor model APIs wrapped behind it.

Why it matters

AI features often chain several JSON payloads (chat, embeddings, tools). Untyped fetch + any hides breakages until a user hits them. Explicit types document the contract for UI state and make refactors safer.

How to do it

  1. Write Request, Success, and ErrorBody types for the endpoint.
  2. Implement async function call(...): Promise<Success> that:

- JSON.stringify the request; - checks res.ok; - parses JSON as unknown; - validates required fields (How to Validate JSON); - throws a typed error or returns a Result union.

  1. Export a narrow public surface; keep vendor quirks private.
  2. Optionally use generics for shared apiPost<TReq, TRes>().
  3. Never put API keys in the client module if it ships to the browser (JavaScript for AI Applications).

Example

export type CreateChatRequest = {
  messages: { role: "user" | "assistant" | "system"; content: string }[];
};

export type CreateChatResponse = {
  id: string;
  text: string;
};

export type ApiError = {
  error: string;
  status: number;
};

export async function createChat(body: CreateChatRequest): Promise<CreateChatResponse> {
  const res = await fetch("/api/chat", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  const data: unknown = await res.json().catch(() => null);
  if (!res.ok) {
    const err = (data && typeof data === "object" && "error" in data)
      ? String((data as { error: unknown }).error)
      : res.statusText;
    const e: ApiError = { error: err, status: res.status };
    throw e;
  }
  if (!data || typeof data !== "object" || !("text" in data) || !("id" in data)) {
    throw { error: "invalid response", status: res.status } satisfies ApiError;
  }
  const { id, text } = data as { id: unknown; text: unknown };
  if (typeof id !== "string" || typeof text !== "string") {
    throw { error: "invalid response fields", status: res.status } satisfies ApiError;
  }
  return { id, text };
}

This is the pattern to reuse for embeddings or tool proxy routes.

When to use it

Add typed clients as soon as two components call the same endpoint, or when you wrap a third-party model API on the server (Calling AI Services from JavaScript and TypeScript).

Common mistakes

  • Returning Promise<any>.
  • Trusting res.json() without checking shape.
  • Sharing one mega-type across unrelated endpoints.
  • Putting secrets in a "client" that is imported by React.