Claude API in TypeScript: Production Patterns and Best Practices
Production-ready TypeScript patterns for the Claude API. Covers SDK setup, type safety, error handling, streaming, middleware patterns, testing strategies, and deployment best practices for TypeScript applications.
Setting Up the TypeScript SDK
The official Anthropic TypeScript SDK provides full type safety, streaming support, and automatic retries. It is the recommended way to interact with the Claude API from any TypeScript or JavaScript project.
npm install @anthropic-ai/sdk
Basic Client Configuration
import Anthropic from "@anthropic-ai/sdk";
// Basic initialization (reads ANTHROPIC_API_KEY from environment)
const client = new Anthropic();
// Explicit configuration
const client = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
maxRetries: 3, // Built-in retry with backoff
timeout: 120_000, // 2 minute timeout
});
Type-Safe Message Creation
The SDK provides complete TypeScript types for all API parameters and responses:
import Anthropic from "@anthropic-ai/sdk";
import {
MessageParam,
ContentBlockParam,
TextBlock,
ToolUseBlock,
} from "@anthropic-ai/sdk/resources/messages";
const client = new Anthropic();
// Strongly typed messages
const messages: MessageParam[] = [
{
role: "user",
content: "Explain the SOLID principles with TypeScript examples.",
},
];
const response = await client.messages.create({
model: "claude-sonnet-4-5-20250514",
max_tokens: 4096,
messages,
});
// Type-safe response handling
for (const block of response.content) {
if (block.type === "text") {
// TypeScript knows block is TextBlock here
console.log(block.text);
} else if (block.type === "tool_use") {
// TypeScript knows block is ToolUseBlock here
console.log(block.name, block.input);
}
}
Typed Tool Definitions
Use Zod schemas to define tool inputs with runtime validation and TypeScript type inference:
import Anthropic from "@anthropic-ai/sdk";
import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";
// Define tool input schemas with Zod
const SearchSchema = z.object({
query: z.string().describe("Search query string"),
category: z.enum(["docs", "code", "issues"]).optional()
.describe("Filter by content category"),
limit: z.number().int().min(1).max(50).default(10)
.describe("Maximum results to return"),
});
type SearchInput = z.infer<typeof SearchSchema>;
const CreateTicketSchema = z.object({
title: z.string().min(1).max(200),
description: z.string(),
priority: z.enum(["low", "medium", "high", "critical"]),
assignee: z.string().email().optional(),
});
type CreateTicketInput = z.infer<typeof CreateTicketSchema>;
// Convert to Claude tool format
const tools: Anthropic.Tool[] = [
{
name: "search",
description: "Search the knowledge base for relevant documents, code, or issues.",
input_schema: zodToJsonSchema(SearchSchema) as Anthropic.Tool.InputSchema,
},
{
name: "create_ticket",
description: "Create a new support ticket in the ticketing system.",
input_schema: zodToJsonSchema(CreateTicketSchema) as Anthropic.Tool.InputSchema,
},
];
// Type-safe tool execution
async function executeTool(name: string, input: unknown): Promise<string> {
switch (name) {
case "search": {
const parsed = SearchSchema.parse(input);
return await performSearch(parsed);
}
case "create_ticket": {
const parsed = CreateTicketSchema.parse(input);
return await createTicket(parsed);
}
default:
throw new Error(`Unknown tool: ${name}`);
}
}
The Agentic Loop Pattern
import Anthropic from "@anthropic-ai/sdk";
interface AgentConfig {
model: string;
maxTokens: number;
systemPrompt: string;
tools: Anthropic.Tool[];
maxIterations: number;
}
interface AgentResult {
response: string;
toolCalls: { name: string; input: unknown; result: string }[];
totalTokens: { input: number; output: number };
iterations: number;
}
async function runAgent(
userMessage: string,
config: AgentConfig,
): Promise<AgentResult> {
const messages: Anthropic.MessageParam[] = [
{ role: "user", content: userMessage },
];
const toolCalls: AgentResult["toolCalls"] = [];
let totalInput = 0;
let totalOutput = 0;
for (let i = 0; i < config.maxIterations; i++) {
const response = await client.messages.create({
model: config.model,
max_tokens: config.maxTokens,
system: config.systemPrompt,
tools: config.tools,
messages,
});
totalInput += response.usage.input_tokens;
totalOutput += response.usage.output_tokens;
if (response.stop_reason === "end_turn") {
const textContent = response.content
.filter((b): b is Anthropic.TextBlock => b.type === "text")
.map((b) => b.text)
.join("");
return {
response: textContent,
toolCalls,
totalTokens: { input: totalInput, output: totalOutput },
iterations: i + 1,
};
}
if (response.stop_reason === "tool_use") {
const toolResults: Anthropic.ToolResultBlockParam[] = [];
for (const block of response.content) {
if (block.type === "tool_use") {
try {
const result = await executeTool(block.name, block.input);
toolCalls.push({ name: block.name, input: block.input, result });
toolResults.push({
type: "tool_result",
tool_use_id: block.id,
content: result,
});
} catch (error) {
toolResults.push({
type: "tool_result",
tool_use_id: block.id,
content: `Error: ${error instanceof Error ? error.message : String(error)}`,
is_error: true,
});
}
}
}
messages.push({ role: "assistant", content: response.content });
messages.push({ role: "user", content: toolResults });
}
}
throw new Error(`Agent exceeded max iterations (${config.maxIterations})`);
}
Streaming Pattern
import Anthropic from "@anthropic-ai/sdk";
async function* streamResponse(
messages: Anthropic.MessageParam[],
options?: { onToolUse?: (name: string, input: unknown) => void },
): AsyncGenerator<string> {
const stream = await client.messages.stream({
model: "claude-sonnet-4-5-20250514",
max_tokens: 4096,
messages,
});
for await (const event of stream) {
if (
event.type === "content_block_delta" &&
event.delta.type === "text_delta"
) {
yield event.delta.text;
}
}
}
// Usage in an Express/Fastify endpoint
app.post("/api/chat", async (req, res) => {
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
const messages = req.body.messages;
for await (const chunk of streamResponse(messages)) {
res.write(`data: ${JSON.stringify({ text: chunk })}\n\n`);
}
res.write("data: [DONE]\n\n");
res.end();
});
Middleware Pattern for Cross-Cutting Concerns
type MessageCreateParams = Anthropic.MessageCreateParams;
type Message = Anthropic.Message;
type Middleware = (
params: MessageCreateParams,
next: (params: MessageCreateParams) => Promise<Message>,
) => Promise<Message>;
class ClaudeClient {
private client: Anthropic;
private middlewares: Middleware[] = [];
constructor() {
this.client = new Anthropic();
}
use(middleware: Middleware): this {
this.middlewares.push(middleware);
return this;
}
async create(params: MessageCreateParams): Promise<Message> {
const chain = this.middlewares.reduceRight(
(next, middleware) => (p: MessageCreateParams) => middleware(p, next),
(p: MessageCreateParams) => this.client.messages.create(p),
);
return chain(params);
}
}
// Logging middleware
const loggingMiddleware: Middleware = async (params, next) => {
const start = Date.now();
console.log(`[Claude] Request: model=${params.model}`);
const response = await next(params);
console.log(
`[Claude] Response: ${response.usage.input_tokens}in/${response.usage.output_tokens}out ` +
`${Date.now() - start}ms`,
);
return response;
};
// Cost tracking middleware
const costMiddleware: Middleware = async (params, next) => {
const response = await next(params);
const COSTS: Record<string, { input: number; output: number }> = {
"claude-sonnet-4-5-20250514": { input: 3, output: 15 },
"claude-haiku-4-5-20250514": { input: 1, output: 5 },
};
const rates = COSTS[params.model] ?? { input: 3, output: 15 };
const cost =
(response.usage.input_tokens * rates.input +
response.usage.output_tokens * rates.output) /
1_000_000;
console.log(`[Cost] $${cost.toFixed(6)}`);
return response;
};
// Usage
const claude = new ClaudeClient();
claude.use(loggingMiddleware).use(costMiddleware);
const response = await claude.create({
model: "claude-sonnet-4-5-20250514",
max_tokens: 4096,
messages: [{ role: "user", content: "Hello" }],
});
Testing Patterns
Mocking the SDK
import { vi, describe, it, expect } from "vitest";
import Anthropic from "@anthropic-ai/sdk";
// Mock the entire SDK
vi.mock("@anthropic-ai/sdk", () => {
return {
default: vi.fn().mockImplementation(() => ({
messages: {
create: vi.fn(),
stream: vi.fn(),
},
})),
};
});
describe("ChatService", () => {
it("should process a simple message", async () => {
const mockCreate = vi.fn().mockResolvedValue({
content: [{ type: "text", text: "Hello! How can I help?" }],
usage: { input_tokens: 10, output_tokens: 8 },
stop_reason: "end_turn",
});
const client = new Anthropic();
(client.messages.create as any) = mockCreate;
const service = new ChatService(client);
const result = await service.chat("Hello");
expect(result).toBe("Hello! How can I help?");
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
model: "claude-sonnet-4-5-20250514",
messages: [{ role: "user", content: "Hello" }],
}),
);
});
it("should handle tool use responses", async () => {
const mockCreate = vi
.fn()
.mockResolvedValueOnce({
content: [
{ type: "tool_use", id: "tool_1", name: "search", input: { query: "test" } },
],
stop_reason: "tool_use",
usage: { input_tokens: 20, output_tokens: 15 },
})
.mockResolvedValueOnce({
content: [{ type: "text", text: "Based on the search results..." }],
stop_reason: "end_turn",
usage: { input_tokens: 50, output_tokens: 30 },
});
// Test the full tool use loop
const client = new Anthropic();
(client.messages.create as any) = mockCreate;
const result = await runAgent("Search for test", agentConfig);
expect(result.toolCalls).toHaveLength(1);
expect(result.toolCalls[0].name).toBe("search");
});
});
Environment Configuration
// config.ts
import { z } from "zod";
const ConfigSchema = z.object({
ANTHROPIC_API_KEY: z.string().startsWith("sk-ant-"),
CLAUDE_MODEL: z.string().default("claude-sonnet-4-5-20250514"),
CLAUDE_MAX_TOKENS: z.coerce.number().default(4096),
CLAUDE_MAX_RETRIES: z.coerce.number().default(3),
CLAUDE_TIMEOUT_MS: z.coerce.number().default(120_000),
});
export const config = ConfigSchema.parse(process.env);
Deployment Considerations
- Keep the SDK updated: Anthropic releases frequent SDK updates with new features and bug fixes. Pin the major version but allow minor/patch updates
- Connection pooling: The SDK manages HTTP connections internally. Create one client instance and reuse it across your application
- Serverless considerations: In AWS Lambda or Vercel Functions, cold starts add 1-2 seconds. Initialize the client outside the handler function so it persists across invocations
- Memory management: Streaming large responses accumulates strings in memory. For very long outputs, process chunks incrementally rather than concatenating everything
NYC News
Expert insights on AI voice agents and customer communication automation.
Try CallSphere AI Voice Agents
See how AI voice agents work for your industry. Live demo available -- no signup required.