[SL_CORE]
< CD ..||6 min read

TypeScript Patterns for LLM Integration: Type-Safe AI

# Type-safe LLM integration in TypeScript prevents runtime errors, improves developer experience, and makes AI code maintainable. Learn the patterns: typed tools, Zod schemas, discriminated unions, and more.

[typescript][llm][patterns][developer-experience]

TypeScript and LLMs are a natural pair — the model returns structured data, TypeScript validates it. But most teams use TypeScript superficially with LLMs: any everywhere, manual JSON parsing, no type safety on tool calls. Here are the patterns that make LLM code genuinely type-safe.

Pattern 1: Typed Tool Definitions

Tool definitions are the core integration surface. Define them with Zod, derive TypeScript types automatically:

import { z } from "zod";
import Anthropic from "@anthropic-ai/sdk";

// Define tool schemas with Zod
const SearchToolSchema = z.object({
  query: z.string().describe("Search query to execute"),
  maxResults: z.number().min(1).max(20).default(5).describe("Number of results"),
  filter: z.enum(["web", "news", "academic"]).optional().describe("Search type"),
});

const RunCodeSchema = z.object({
  language: z.enum(["python", "javascript", "typescript", "bash"]),
  code: z.string().describe("Code to execute in a sandbox"),
  timeout: z.number().max(30).default(10).describe("Timeout in seconds"),
});

// Derive types from schemas
type SearchToolInput = z.infer<typeof SearchToolSchema>;
type RunCodeInput = z.infer<typeof RunCodeSchema>;

// Convert Zod schema to Anthropic tool format
function zodToAnthropicTool(
  name: string,
  description: string,
  schema: z.ZodObject<any>
): Anthropic.Tool {
  return {
    name,
    description,
    input_schema: zodToJsonSchema(schema) as Anthropic.Tool["input_schema"],
  };
}

const tools: Anthropic.Tool[] = [
  zodToAnthropicTool("search", "Search the web for information", SearchToolSchema),
  zodToAnthropicTool("run_code", "Execute code in a sandbox", RunCodeSchema),
];

Pattern 2: Discriminated Union for Tool Results

Type tool execution with a discriminated union — no any, no unchecked casts:

type ToolResult =
  | { toolName: "search"; result: SearchResult[] }
  | { toolName: "run_code"; result: CodeExecutionResult }
  | { toolName: string; result: unknown; error: string };

interface SearchResult {
  url: string;
  title: string;
  snippet: string;
}

interface CodeExecutionResult {
  stdout: string;
  stderr: string;
  exitCode: number;
  duration: number;
}

// Typed tool executor
async function executeTool(
  toolName: string,
  rawInput: unknown
): Promise<ToolResult> {
  switch (toolName) {
    case "search": {
      const input = SearchToolSchema.parse(rawInput);
      const results = await searchWeb(input.query, input.maxResults, input.filter);
      return { toolName: "search", result: results };
    }
    case "run_code": {
      const input = RunCodeSchema.parse(rawInput);
      const result = await executeCode(input);
      return { toolName: "run_code", result };
    }
    default:
      return { toolName, result: null, error: `Unknown tool: ${toolName}` };
  }
}

The Zod .parse() call validates and narrows the type — if the LLM sends malformed tool arguments, you get a typed error, not a runtime crash.

Pattern 3: Typed Agent Loop

interface AgentState {
  messages: Anthropic.MessageParam[];
  toolResults: ToolResult[];
  iterationCount: number;
  totalCost: number;
}

type AgentOutcome =
  | { status: "success"; answer: string; state: AgentState }
  | { status: "max_iterations"; partialAnswer: string; state: AgentState }
  | { status: "error"; error: string; state: AgentState };

async function runAgent(
  goal: string,
  maxIterations = 10
): Promise<AgentOutcome> {
  const state: AgentState = {
    messages: [{ role: "user", content: goal }],
    toolResults: [],
    iterationCount: 0,
    totalCost: 0,
  };

  while (state.iterationCount < maxIterations) {
    state.iterationCount++;

    const response = await anthropic.messages.create({
      model: "claude-sonnet-4-6",
      max_tokens: 4096,
      tools,
      messages: state.messages,
    });

    state.totalCost += calculateCost("claude-sonnet-4-6", response.usage);
    state.messages.push({ role: "assistant", content: response.content });

    if (response.stop_reason === "end_turn") {
      const text = response.content
        .filter((b): b is Anthropic.TextBlock => b.type === "text")
        .map((b) => b.text)
        .join("");
      return { status: "success", answer: text, state };
    }

    if (response.stop_reason === "tool_use") {
      const toolUseBlocks = response.content.filter(
        (b): b is Anthropic.ToolUseBlock => b.type === "tool_use"
      );

      const toolResults: Anthropic.ToolResultBlockParam[] = await Promise.all(
        toolUseBlocks.map(async (block) => {
          const result = await executeTool(block.name, block.input);
          state.toolResults.push(result);

          return {
            type: "tool_result" as const,
            tool_use_id: block.id,
            content: "error" in result
              ? `Error: ${result.error}`
              : JSON.stringify(result.result, null, 2),
          };
        })
      );

      state.messages.push({ role: "user", content: toolResults });
    }
  }

  return {
    status: "max_iterations",
    partialAnswer: "Max iterations reached",
    state,
  };
}

TypeScript type guards (b is Anthropic.TextBlock) narrow response content without type assertions.

Pattern 4: Type-Safe Structured Output

Use generateObject from the AI SDK for guaranteed-type responses:

import { generateObject } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { z } from "zod";

const CodeReviewSchema = z.object({
  severity: z.enum(["critical", "high", "medium", "low"]),
  issues: z.array(z.object({
    line: z.number().optional(),
    description: z.string(),
    suggestion: z.string(),
  })),
  overallScore: z.number().min(0).max(10),
  summary: z.string(),
});

type CodeReview = z.infer<typeof CodeReviewSchema>;

async function reviewCode(code: string, language: string): Promise<CodeReview> {
  const { object } = await generateObject({
    model: anthropic("claude-sonnet-4-6"),
    schema: CodeReviewSchema,
    prompt: `Review this ${language} code for bugs, security issues, and best practices:\n\`\`\`${language}\n${code}\n\`\`\``,
  });

  // TypeScript knows `object` is CodeReview — no casting needed
  return object;
}

// Fully typed usage
const review = await reviewCode(myCode, "typescript");
review.issues.forEach((issue) => {
  console.log(`[${issue.line ?? "?"}] ${issue.description}`);
  // TypeScript knows issue.line is number | undefined, etc.
});

Pattern 5: Message Builder with Fluent API

Avoid manual message array construction:

class MessageBuilder {
  private messages: Anthropic.MessageParam[] = [];

  user(content: string | Anthropic.ContentBlockParam[]): this {
    this.messages.push({ role: "user", content });
    return this;
  }

  assistant(content: string | Anthropic.ContentBlockParam[]): this {
    this.messages.push({ role: "assistant", content });
    return this;
  }

  withToolResult(toolUseId: string, result: unknown): this {
    const toolResult: Anthropic.ToolResultBlockParam = {
      type: "tool_result",
      tool_use_id: toolUseId,
      content: typeof result === "string" ? result : JSON.stringify(result),
    };
    this.messages.push({ role: "user", content: [toolResult] });
    return this;
  }

  build(): Anthropic.MessageParam[] {
    return this.messages;
  }
}

// Usage
const messages = new MessageBuilder()
  .user("What's the weather in Tokyo?")
  .assistant([
    { type: "text", text: "Let me check that for you." },
    { type: "tool_use", id: "tool_1", name: "get_weather", input: { city: "Tokyo" } },
  ])
  .withToolResult("tool_1", { temperature: 22, condition: "sunny" })
  .build();

Pattern 6: Error Types

Replace string errors with typed error classes:

class LLMError extends Error {
  constructor(
    message: string,
    public readonly type: "rate_limit" | "context_overflow" | "invalid_request" | "server_error",
    public readonly retryable: boolean,
    public readonly statusCode?: number
  ) {
    super(message);
    this.name = "LLMError";
  }
}

function handleAnthropicError(err: unknown): LLMError {
  if (err instanceof Anthropic.APIError) {
    if (err.status === 429) return new LLMError("Rate limited", "rate_limit", true, 429);
    if (err.status === 400) return new LLMError(err.message, "invalid_request", false, 400);
    if (err.status >= 500) return new LLMError("Server error", "server_error", true, err.status);
  }
  return new LLMError(String(err), "server_error", false);
}

async function safeCreate(req: Anthropic.MessageCreateParamsNonStreaming) {
  try {
    return await anthropic.messages.create(req);
  } catch (err) {
    const typed = handleAnthropicError(err);
    if (typed.retryable) {
      // Use your retry logic
    }
    throw typed;
  }
}

FAQ

Should I use @anthropic-ai/sdk types or define my own? Use the SDK types for API objects (messages, content blocks, usage). Define your own types for your application's domain (tools, agent state, outcomes). Never re-derive what the SDK already provides.

Zod or io-ts for runtime validation? Zod in 2026. Better TypeScript integration, cleaner API, faster parsing. io-ts is still valid for functional programming patterns, but Zod is the default.

How do I type streaming responses? Use the AI SDK's useChat / streamText return types — they're fully typed. For raw Anthropic SDK streaming, the stream() method returns typed async iterators over MessageStreamEvent.

Should LLM calls be async or use observables? Async/await for single calls. For streaming, use async generators or the AI SDK's abstraction. Observables (RxJS) are overkill unless you're building complex reactive pipelines.

What TypeScript strict settings should I enable? At minimum: strict: true, noUncheckedIndexedAccess: true. These prevent the common bug of treating array[0] as non-null when it might be undefined (common with LLM-returned arrays).