n4nAI

Building a typed fetch wrapper for chat completions

A hands-on tutorial for building a TypeScript typed fetch wrapper for chat completions with full type safety, retries, and minimal dependencies.

n4n Team3 min read585 words

Audio narration

Coming soon — every post will get a voice note here.

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 fetch available)
  • 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.

Tagstypescriptfetchsdkllm-api

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All typescript typed llm api clients posts →