Claude API TypeScript Integration Example
If you're searching for a Claude API TypeScript integration example, you're probably trying to figure out how to structure types for requests and responses, handle streaming correctly, and avoid the common pitfalls of calling an LLM API from a typed codebase. This article walks through a working example — project setup, typed request/response shapes, streaming with async iterators, tool use, and error handling — using plain fetch so it works in Node, Deno, or edge runtimes without extra dependencies.
The core challenge with TypeScript and any LLM API isn't syntax, it's modeling a response that can be plain text, structured tool calls, or a stream of partial events, all with the same base types. The example below covers all three cases.
Project setup
You need Node 18+ (for native fetch) or a bundler that polyfills it. No SDK is required — a typed wrapper around fetch is often cleaner than a heavy client library, and it keeps your bundle size small if you're targeting edge functions.
mkdir claude-ts-example && cd claude-ts-example
npm init -y
npm install -D typescript @types/node
npx tsc --init
Set "target": "ES2022" and "module": "NodeNext" in tsconfig.json so top-level await and native fetch types resolve correctly.
Typing the request and response
Here's a minimal but realistic type layer for a messages-style API. These types map directly onto what you'd send to Anthropic's Messages API or an equivalent wrapper:
interface Message {
role: "user" | "assistant";
content: string | ContentBlock[];
}
interface ContentBlock {
type: "text" | "tool_use" | "tool_result";
text?: string;
id?: string;
name?: string;
input?: Record<string, unknown>;
}
interface MessageRequest {
model: string;
max_tokens: number;
messages: Message[];
system?: string;
stream?: boolean;
tools?: ToolDefinition[];
}
interface ToolDefinition {
name: string;
description: string;
input_schema: Record<string, unknown>;
}
interface MessageResponse {
id: string;
role: "assistant";
content: ContentBlock[];
stop_reason: string;
usage: { input_tokens: number; output_tokens: number };
}
These types stay useful regardless of which API or wrapper you call — the shape is consistent across Claude-compatible endpoints, including SubToAPI, which exposes the same message structure behind a simpler sub_live_... application key.
A typed client function
async function sendMessage(req: MessageRequest): Promise<MessageResponse> {
const res = await fetch("https://api.subtoapi.app/v1/messages", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${process.env.SUBTOAPI_KEY}`,
},
body: JSON.stringify(req),
});
if (!res.ok) {
const errBody = await res.text();
throw new Error(`Claude API error ${res.status}: ${errBody}`);
}
return res.json() as Promise<MessageResponse>;
}
Usage:
const response = await sendMessage({
model: "claude-sonnet-4",
max_tokens: 512,
messages: [{ role: "user", content: "Summarize this ticket in two sentences." }],
});
console.log(response.content[0].text);
This same function works whether you call the provider directly or through a gateway — the request/response contract doesn't change. See /docs/quickstart and /docs/messages for the exact request fields SubToAPI expects.
Streaming with async iterators
Streaming is where untyped JavaScript code tends to get messy — events arrive as server-sent events, and you need to parse, buffer, and type each chunk. Here's a clean pattern using an async generator:
interface StreamEvent {
type: "content_block_delta" | "message_stop" | string;
delta?: { text?: string };
}
async function* streamMessage(req: MessageRequest): AsyncGenerator<string> {
const res = await fetch("https://api.subtoapi.app/v1/messages", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${process.env.SUBTOAPI_KEY}`,
},
body: JSON.stringify({ ...req, stream: true }),
});
if (!res.body) throw new Error("No response body");
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) {
if (!line.startsWith("data: ")) continue;
const data = line.slice(6);
if (data === "[DONE]") return;
const event: StreamEvent = JSON.parse(data);
if (event.type === "content_block_delta" && event.delta?.text) {
yield event.delta.text;
}
}
}
}
Calling it is as simple as:
for await (const chunk of streamMessage({
model: "claude-sonnet-4",
max_tokens: 1024,
messages: [{ role: "user", content: "Write a haiku about TypeScript." }],
})) {
process.stdout.write(chunk);
}
The async generator approach keeps backpressure handling and buffering in one place, which matters because partial UTF-8 bytes can split across chunks — the { stream: true } flag on TextDecoder.decode prevents garbled characters at chunk boundaries. Full event shapes are documented at /docs/streaming.
Typing tool use
Tool use responses mix text and tool_use blocks in the same content array, so your handling code needs a type guard:
function isToolUse(block: ContentBlock): block is ContentBlock & { type: "tool_use" } {
return block.type === "tool_use";
}
for (const block of response.content) {
if (isToolUse(block)) {
console.log(`Tool called: ${block.name}`, block.input);
// execute the tool, then send a tool_result message back
} else if (block.type === "text") {
console.log(block.text);
}
}
This pattern scales to multi-tool setups without any casts anywhere in the loop. See /docs/tools for the exact input_schema format tools expect.
Error handling that matters in production
Rate limits, timeouts, and malformed responses are the three failures you'll hit most. A typed error wrapper avoids silent undefined access:
class ClaudeApiError extends Error {
constructor(public status: number, public body: string) {
super(`Claude API error ${status}`);
}
}
Throw this from your fetch wrapper instead of a generic Error, and catch it specifically at call sites to decide whether to retry (429, 503) or fail fast (400, 401).
Why use a gateway instead of raw provider calls
Writing this client once is easy. Maintaining API key rotation, per-team usage tracking, and seat-based billing on top of raw provider access is the part teams underestimate. SubToAPI sits in front of your Claude access and gives you sub_live_... application keys, usage metadata per request, and team seats, without changing the request shape shown above. Plans start at Solo €9/month, with Team and Scale tiers for multi-seat setups — see /pricing — and every plan starts with a free trial via /signup.
Questions
Do I need the official Anthropic SDK for a TypeScript integration? No. Native fetch with the typed wrappers above covers requests, streaming, and tool use without adding a dependency, and it works identically in Node, Deno, and edge runtimes.
How do I type streaming responses safely? Define a StreamEvent union based on the type field, parse each SSE line through JSON.parse, and use an async generator to yield only the text deltas you need — this avoids any leaking into your UI code.
Can this same code call SubToAPI instead of calling Claude directly? Yes. The request and response shapes match, so you only need to change the base URL and use a sub_live_... key; see /docs/messages for the full field reference.