Most TypeScript LLM integrations begin as a raw fetch to an OpenAI-compatible endpoint with a loosely typed response. This tutorial builds a typescript typed fetch wrapper chat completions that locks down the request and response contracts, adds retry logic, and keeps dependencies at zero. You will end up with a small client you can point at any compliant API.
Prerequisites
- Node.js 18+ (global
fetchavailable) - TypeScript 5.0+ with
strict: true - An API key for an OpenAI-compatible service
- Basic familiarity with
async/await
Initialize a project if you haven’t:
mkdir ts-chat-client && cd ts-chat-client
npm init -y
npm install -D typescript
npx tsc --init --strict
Confirm tsconfig.json has "target": "ES2022" and "module": "NodeNext" so the fetch types resolve.
Defining the contract
We start with the minimal subset of the OpenAI chat completion schema that matters for typing. Avoid pulling the entire SDK; declare only what you use.
export type Role = "system" | "user" | "assistant" | "tool";
export interface ChatMessage {
role: Role;
content: string;
name?: string;
}
export interface ChatCompletionRequest {
model: string;
messages: ChatMessage[];
temperature?: number;
top_p?: number;
max_tokens?: number;
stream?: boolean;
}
export interface Usage {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
}
export interface ChatCompletionChoice {
index: number;
message: ChatMessage;
finish_reason: string | null;
}
export interface ChatCompletionResponse {
id: string;
object: "chat.completion";
created: number;
model: string;
choices: ChatCompletionChoice[];
usage: Usage;
}
These interfaces give you compile-time checks on every call. If you forget messages, TypeScript fails before runtime. Try this intentional mistake:
// @ts-expect-error - model is missing
chat({ messages: [] });
The // @ts-expect-error directive will pass only because the type system correctly rejects the call.
Building the wrapper
We encapsulate the endpoint, API key, and default headers in a factory. The returned function accepts a ChatCompletionRequest and returns a typed promise.
interface ClientOptions {
baseUrl: string;
apiKey: string;
defaultModel?: string;
}
export function createChatClient(opts: ClientOptions) {
const { baseUrl, apiKey, defaultModel } = opts;
return async function chat(
req: Omit<ChatCompletionRequest, "model"> & { model?: string }
): Promise<ChatCompletionResponse> {
const model = req.model ?? defaultModel;
if (!model) throw new Error("model is required");
const body: ChatCompletionRequest = { ...req, model };
const res = await fetch(`${baseUrl}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Chat request failed: ${res.status} ${text}`);
}
return (await res.json()) as ChatCompletionResponse;
};
}
The Omit trick lets you set a default model at client creation while still allowing per-call override. This is the core of a typescript typed fetch wrapper chat completions that adapts to multiple models without duplicating functions.
Adding retries with backoff
Providers throttle. A production wrapper needs to handle 429s and transient 5xx. We wrap the fetch in a retry loop.
async function fetchWithRetry(
url: string,
init: RequestInit,
maxRetries = 3
): Promise<Response> {
let lastErr: Error | undefined;
for (let i = 0; i < maxRetries; i++) {
const res = await fetch(url, init);
if (res.ok) return res;
if (res.status === 429 || res.status >= 500) {
const backoff = Math.min(1000 * 2 ** i, 8000);
await new Promise((r) => setTimeout(r, backoff));
lastErr = new Error(`Retry ${i + 1} after ${backoff}ms`);
continue;
}
throw new Error(`Fatal: ${res.status} ${await res.text()}`);
}
throw lastErr ?? new Error("Unknown retry failure");
}
Swap the inner fetch in the client with fetchWithRetry. This keeps the wrapper honest under load.
Using the wrapper
Create a .env or hardcode for the example. Below we call a local-compatible endpoint.
import { createChatClient } from "./client";
const chat = createChatClient({
baseUrl: "https://api.openai.com/v1",
apiKey: process.env.OPENAI_API_KEY!,
defaultModel: "gpt-4o-mini",
});
async function main() {
const resp = await chat({
messages: [
{ role: "system", content: "You are a terse bot." },
{ role: "user", content: "Say hello in 5 words." },
],
temperature: 0.2,
});
console.log(resp.choices[0].message.content);
console.log("tokens:", resp.usage.total_tokens);
}
main().catch(console.error);
Expected output
Given a successful response, stdout looks like:
Hello! Hope you're doing well.
tokens: 18
The typed resp.usage.total_tokens is number, not any. The full response shape on the wire matches:
{
"id": "chatcmpl-123",
"object": "chat.completion",
"created": 1699000000,
"model": "gpt-4o-mini",
"choices": [
{
"index": 0,
"message": { "role": "assistant", "content": "Hello! Hope you're doing well." },
"finish_reason": "stop"
}
],
"usage": { "prompt_tokens": 12, "completion_tokens": 6, "total_tokens": 18 }
}
If the API returns a malformed shape, TypeScript won’t catch it at runtime, but your own code can’t accidentally access resp.choices[0].mesage (typo fails compile).
Streaming without losing types
The non-streaming path is enough for many apps. If you need tokens as they arrive, add a separate method that returns AsyncIterable<string>. Keep the same request type, just set stream: true and parse SSE.
export async function* streamChat(
baseUrl: string,
apiKey: string,
req: ChatCompletionRequest
): AsyncGenerator<string> {
const res = await fetch(`${baseUrl}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({ ...req, stream: true }),
});
if (!res.ok) throw new Error(`Stream failed: ${res.status}`);
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.replace(/^data: /, "").trim();
if (!trimmed || trimmed === "[DONE]") continue;
yield JSON.parse(trimmed).choices[0].delta.content ?? "";
}
}
}
This generator yields strings; the request type stays shared with the batch wrapper.
Pointing at a gateway
The same typescript typed fetch wrapper chat completions works against any OpenAI-compatible base URL. If you point baseUrl at n4n.ai, you hit one OpenAI-compatible endpoint that addresses 240+ models and automatically falls back when a provider is rate-limited or degraded—no wrapper changes required. Your typed model string just becomes a routing hint.
const chat = createChatClient({
baseUrl: "https://api.n4n.ai/v1",
apiKey: process.env.N4N_KEY!,
defaultModel: "anthropic/claude-3.5-sonnet",
});
Because the wire format is identical, the interfaces we defined earlier remain valid.
Error typing for callers
Rather than throwing Error with a string, expose a discriminated union so callers can branch.
export type ChatResult =
| { ok: true; data: ChatCompletionResponse }
| { ok: false; status: number; message: string };
export async function safeChat(
chat: ReturnType<typeof createChatClient>,
req: Parameters<ReturnType<typeof createChatClient>>[0]
): Promise<ChatResult> {
try {
const data = await chat(req);
return { ok: true, data };
} catch (e) {
return { ok: false, status: 0, message: (e as Error).message };
}
}
Now consumers get exhaustiveness checks:
const r = await safeChat(chat, { messages: [{ role: "user", content: "hi" }] });
if (!r.ok) {
console.error(r.status, r.message);
} else {
console.log(r.data.choices[0].message.content);
}
Why not just use the official SDK?
Official SDKs bundle retries, polling, and websockets you may never use. They also lag behind new model parameters. A typed fetch wrapper gives you exact control: you import only the types you declare, and the network layer is plain fetch. For a gateway that already handles routing and fallback, the SDK adds weight without benefit.
Wrapping up the wrapper
You now have a zero-dependency, strictly typed client for chat completions. It validates requests at compile time, retries on throttle, and streams if needed. The full surface area is under 150 lines. Swap the base URL to any compatible gateway and the types hold.
That’s the core of a maintainable typescript typed fetch wrapper chat completions without dragging in a heavy SDK.