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
- Write
Request,Success, andErrorBodytypes for the endpoint. - 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.
- Export a narrow public surface; keep vendor quirks private.
- Optionally use generics for shared
apiPost<TReq, TRes>(). - 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.