Skip to content
Learn Agentic AI11 min read0 views

TypeScript for Agentic AI: Building Type-Safe Agent Systems

Build type-safe agentic AI with TypeScript — typed tool definitions, Zod schemas for structured output, type-safe handoffs, and generic agent classes.

Why TypeScript for Agentic AI?

Python dominates the agentic AI ecosystem, but TypeScript has compelling advantages for production agent systems. Static types catch entire categories of bugs at compile time — wrong tool argument types, missing handoff targets, malformed LLM responses. When your agent system grows beyond a few files, TypeScript's type system becomes a safety net that prevents the subtle runtime errors that plague dynamically typed agent code.

TypeScript also shines for full-stack teams. If your frontend is React or Next.js, building the agent backend in TypeScript means shared types between the API layer and the UI, a single language across the entire stack, and a unified development experience.

This guide covers how to build type-safe agentic AI systems in TypeScript using Zod for schema validation, generics for reusable agent patterns, and the OpenAI and Anthropic TypeScript SDKs.

Setting Up the Project

Initialize a TypeScript project with the required dependencies:

mkdir ts-agent && cd ts-agent
npm init -y
npm install typescript tsx @types/node zod openai @anthropic-ai/sdk
npm install -D @types/node
npx tsc --init

Configure tsconfig.json for strict type checking:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "declaration": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": false
  },
  "include": ["src/**/*"]
}

The noUncheckedIndexedAccess flag is particularly useful for agent development — it forces you to handle the case where an object property or array element might be undefined, which is common when parsing LLM responses.

Type-Safe Tool Definitions

The foundation of a type-safe agent system is typed tool definitions. Each tool should have its input and output types defined with Zod schemas:

// src/tools/types.ts
import { z } from "zod";

export interface ToolDefinition<
  TInput extends z.ZodTypeAny = z.ZodTypeAny,
  TOutput extends z.ZodTypeAny = z.ZodTypeAny,
> {
  name: string;
  description: string;
  inputSchema: TInput;
  outputSchema: TOutput;
  execute: (input: z.infer<TInput>) => Promise<z.infer<TOutput>>;
}

export function defineTool<
  TInput extends z.ZodTypeAny,
  TOutput extends z.ZodTypeAny,
>(config: {
  name: string;
  description: string;
  inputSchema: TInput;
  outputSchema: TOutput;
  execute: (input: z.infer<TInput>) => Promise<z.infer<TOutput>>;
}): ToolDefinition<TInput, TOutput> {
  return config;
}

Now define concrete tools with full type safety:

// src/tools/order-tools.ts
import { z } from "zod";
import { defineTool } from "./types";

const OrderStatusSchema = z.object({
  orderId: z.string(),
  status: z.enum([
    "pending", "processing", "shipped",
    "delivered", "cancelled",
  ]),
  trackingNumber: z.string().nullable(),
  estimatedDelivery: z.string().nullable(),
});

export type OrderStatus = z.infer<typeof OrderStatusSchema>;

export const lookupOrder = defineTool({
  name: "lookup_order",
  description:
    "Look up the current status of an order by order ID",
  inputSchema: z.object({
    orderId: z.string().describe("Order ID (format: ORD-XXXXX)"),
  }),
  outputSchema: OrderStatusSchema,
  execute: async (input) => {
    // input is typed as { orderId: string }
    const order = await db.orders.findUnique({
      where: { id: input.orderId },
    });

    if (!order) {
      throw new ToolError(
        `Order ${input.orderId} not found`
      );
    }

    // Return type is enforced as OrderStatus
    return {
      orderId: order.id,
      status: order.status,
      trackingNumber: order.trackingNumber,
      estimatedDelivery: order.estimatedDelivery,
    };
  },
});

export const cancelOrder = defineTool({
  name: "cancel_order",
  description:
    "Cancel an order. Only works for unshipped orders.",
  inputSchema: z.object({
    orderId: z.string(),
    reason: z.string().describe("Reason for cancellation"),
  }),
  outputSchema: z.object({
    success: z.boolean(),
    message: z.string(),
  }),
  execute: async (input) => {
    // input is typed as { orderId: string; reason: string }
    const order = await db.orders.findUnique({
      where: { id: input.orderId },
    });

    if (!order) {
      return { success: false, message: "Order not found" };
    }
    if (order.status === "shipped") {
      return {
        success: false,
        message: "Cannot cancel a shipped order",
      };
    }

    await db.orders.update({
      where: { id: input.orderId },
      data: { status: "cancelled", cancelReason: input.reason },
    });

    return {
      success: true,
      message: `Order ${input.orderId} cancelled`,
    };
  },
});

The key benefit: if you change the OrderStatusSchema, every tool that returns an OrderStatus will show a compile error until updated. No runtime surprises.

Zod Schemas for Structured Agent Output

LLMs return unstructured text by default. Zod lets you enforce structure on agent outputs, catching malformed responses before they reach your application logic:

See AI Voice Agents Handle Real Calls

Book a free demo or calculate how much you can save with AI voice automation.

// src/agents/structured-output.ts
import { z } from "zod";
import Anthropic from "@anthropic-ai/sdk";

const client = new Anthropic();

const AgentDecisionSchema = z.object({
  action: z.enum(["respond", "use_tool", "handoff", "escalate"]),
  reasoning: z.string().describe("Why the agent chose this action"),
  toolName: z.string().optional(),
  toolArgs: z.record(z.unknown()).optional(),
  handoffTarget: z.string().optional(),
  response: z.string().optional(),
});

type AgentDecision = z.infer<typeof AgentDecisionSchema>;

async function getStructuredDecision(
  systemPrompt: string,
  messages: Array<{ role: "user" | "assistant"; content: string }>,
): Promise<AgentDecision> {
  const response = await client.messages.create({
    model: "claude-sonnet-4-20250514",
    max_tokens: 1024,
    system: `${systemPrompt}

Always respond with a JSON object matching this schema:
{
  "action": "respond" | "use_tool" | "handoff" | "escalate",
  "reasoning": "string explaining your decision",
  "toolName": "optional tool name",
  "toolArgs": { optional tool arguments },
  "handoffTarget": "optional agent name",
  "response": "optional text response to user"
}`,
    messages,
  });

  const text = response.content[0].type === "text"
    ? response.content[0].text
    : "";

  // Extract JSON from the response
  const jsonMatch = text.match(/\{[\s\S]*\}/);
  if (!jsonMatch) {
    throw new Error("Agent did not return valid JSON");
  }

  const parsed = JSON.parse(jsonMatch[0]);

  // Validate with Zod — throws if schema doesn't match
  return AgentDecisionSchema.parse(parsed);
}

This pattern ensures your application code always receives a well-typed AgentDecision object, regardless of what the LLM actually outputs. If the LLM returns malformed JSON or missing fields, Zod throws a descriptive error that you can handle gracefully.

Generic Agent Class

Build a reusable, generic agent class that works with any set of typed tools:

// src/agents/agent.ts
import { z } from "zod";
import Anthropic from "@anthropic-ai/sdk";
import { ToolDefinition } from "../tools/types";

interface AgentConfig {
  name: string;
  instructions: string;
  model?: string;
  maxIterations?: number;
}

interface AgentResult {
  response: string;
  toolsUsed: string[];
  iterations: number;
}

export class Agent {
  private client: Anthropic;
  private tools: ToolDefinition[];
  private config: AgentConfig;

  constructor(config: AgentConfig, tools: ToolDefinition[]) {
    this.client = new Anthropic();
    this.config = config;
    this.tools = tools;
  }

  private formatToolsForAPI(): Anthropic.Tool[] {
    return this.tools.map((tool) => ({
      name: tool.name,
      description: tool.description,
      input_schema: this.zodToJsonSchema(tool.inputSchema),
    }));
  }

  private zodToJsonSchema(schema: z.ZodTypeAny): Record<string, unknown> {
    // Simplified Zod-to-JSON-Schema conversion
    // In production, use the zod-to-json-schema library
    if (schema instanceof z.ZodObject) {
      const shape = schema.shape;
      const properties: Record<string, unknown> = {};
      const required: string[] = [];

      for (const [key, value] of Object.entries(shape)) {
        const zodValue = value as z.ZodTypeAny;
        properties[key] = this.zodToJsonSchema(zodValue);
        if (!zodValue.isOptional()) {
          required.push(key);
        }
      }

      return { type: "object", properties, required };
    }

    if (schema instanceof z.ZodString) {
      return { type: "string" };
    }

    if (schema instanceof z.ZodNumber) {
      return { type: "number" };
    }

    if (schema instanceof z.ZodBoolean) {
      return { type: "boolean" };
    }

    return { type: "string" };
  }

  async run(
    userMessage: string,
    history: Anthropic.MessageParam[] = [],
  ): Promise<AgentResult> {
    const messages: Anthropic.MessageParam[] = [
      ...history,
      { role: "user", content: userMessage },
    ];

    const toolsUsed: string[] = [];
    const maxIter = this.config.maxIterations ?? 10;

    for (let i = 0; i < maxIter; i++) {
      const response = await this.client.messages.create({
        model: this.config.model ?? "claude-sonnet-4-20250514",
        max_tokens: 4096,
        system: this.config.instructions,
        tools: this.formatToolsForAPI(),
        messages,
      });

      if (response.stop_reason === "tool_use") {
        messages.push({
          role: "assistant",
          content: response.content,
        });

        const toolResults: Anthropic.ToolResultBlockParam[] = [];

        for (const block of response.content) {
          if (block.type !== "tool_use") continue;

          const tool = this.tools.find(
            (t) => t.name === block.name
          );

          if (!tool) {
            toolResults.push({
              type: "tool_result",
              tool_use_id: block.id,
              content: JSON.stringify({
                error: `Unknown tool: ${block.name}`,
              }),
            });
            continue;
          }

          try {
            // Validate input with Zod
            const validatedInput = tool.inputSchema.parse(
              block.input
            );
            const result = await tool.execute(validatedInput);

            // Validate output with Zod
            const validatedOutput = tool.outputSchema.parse(
              result
            );
            toolsUsed.push(block.name);

            toolResults.push({
              type: "tool_result",
              tool_use_id: block.id,
              content: JSON.stringify(validatedOutput),
            });
          } catch (error) {
            const msg =
              error instanceof Error
                ? error.message
                : "Unknown error";
            toolResults.push({
              type: "tool_result",
              tool_use_id: block.id,
              content: JSON.stringify({ error: msg }),
            });
          }
        }

        messages.push({ role: "user", content: toolResults });
      } else {
        const textBlock = response.content.find(
          (b) => b.type === "text"
        );
        return {
          response: textBlock?.text ?? "",
          toolsUsed,
          iterations: i + 1,
        };
      }
    }

    return {
      response: "Maximum iterations reached.",
      toolsUsed,
      iterations: maxIter,
    };
  }
}

Type-Safe Agent Handoffs

Handoffs between agents are error-prone without type safety. Define a handoff system where the compiler enforces valid handoff targets:

// src/agents/handoffs.ts

type AgentName = "triage" | "orders" | "support" | "billing";

interface HandoffConfig {
  allowedTargets: AgentName[];
}

type HandoffMap = Record<AgentName, HandoffConfig>;

const HANDOFF_MAP: HandoffMap = {
  triage: {
    allowedTargets: ["orders", "support", "billing"],
  },
  orders: {
    allowedTargets: ["triage"],
  },
  support: {
    allowedTargets: ["triage"],
  },
  billing: {
    allowedTargets: ["triage", "orders"],
  },
};

class AgentOrchestrator {
  private agents: Map<AgentName, Agent>;
  private activeAgent: AgentName;

  constructor(agents: Map<AgentName, Agent>) {
    this.agents = agents;
    this.activeAgent = "triage";
  }

  handoff(target: AgentName): void {
    const config = HANDOFF_MAP[this.activeAgent];

    if (!config.allowedTargets.includes(target)) {
      throw new Error(
        `Agent "${this.activeAgent}" cannot hand off to ` +
        `"${target}". Allowed: ${config.allowedTargets.join(", ")}`
      );
    }

    this.activeAgent = target;
  }
}

If someone tries to add a new agent name without updating the AgentName type, the compiler will flag every place that needs updating. If someone configures an invalid handoff target, the compiler catches it before runtime.

Error Handling with Discriminated Unions

TypeScript's discriminated unions are excellent for modeling agent operation results:

// src/agents/results.ts

type ToolResult =
  | { kind: "success"; data: unknown; toolName: string }
  | { kind: "error"; error: string; toolName: string; retryable: boolean }
  | { kind: "timeout"; toolName: string; durationMs: number };

function handleToolResult(result: ToolResult): string {
  switch (result.kind) {
    case "success":
      return JSON.stringify(result.data);
    case "error":
      if (result.retryable) {
        return `Tool ${result.toolName} failed but can be retried: ${result.error}`;
      }
      return `Tool ${result.toolName} failed permanently: ${result.error}`;
    case "timeout":
      return `Tool ${result.toolName} timed out after ${result.durationMs}ms`;
  }
  // TypeScript knows this is exhaustive — no default needed
}

The compiler ensures every case is handled. If you add a new result kind later, TypeScript will flag every switch statement that needs updating.

Integration with Express or Fastify

Expose your type-safe agent through an API:

// src/server.ts
import Fastify from "fastify";
import { z } from "zod";
import { Agent } from "./agents/agent";
import { lookupOrder, cancelOrder } from "./tools/order-tools";

const app = Fastify({ logger: true });

const ChatRequestSchema = z.object({
  message: z.string().min(1).max(10000),
  conversationId: z.string().uuid().optional(),
});

const orderAgent = new Agent(
  {
    name: "Order Support",
    instructions: "You help customers with order inquiries.",
  },
  [lookupOrder, cancelOrder],
);

app.post("/chat", async (request, reply) => {
  const body = ChatRequestSchema.safeParse(request.body);

  if (!body.success) {
    return reply.status(400).send({
      error: "Invalid request",
      details: body.error.issues,
    });
  }

  const result = await orderAgent.run(body.data.message);

  return reply.send({
    response: result.response,
    toolsUsed: result.toolsUsed,
  });
});

app.listen({ port: 3000 });

At CallSphere, our TypeScript agent services use this pattern — Zod validation at the API boundary, typed tools in the agent layer, and discriminated unions for result handling — to maintain type safety from HTTP request to LLM response to tool execution and back.

Frequently Asked Questions

Is TypeScript slower than Python for agentic AI?

The runtime performance difference between TypeScript (Node.js) and Python is negligible for agentic AI workloads because the bottleneck is always the LLM API call, which takes 500-3000ms regardless of the host language. Where TypeScript wins is developer productivity at scale — type checking catches bugs that Python would only surface at runtime, and IDE autocompletion for tool definitions and agent configurations significantly speeds up development on larger codebases.

How do I use Zod with OpenAI's structured outputs?

OpenAI's API supports a response_format parameter with a JSON schema for structured outputs. You can convert Zod schemas to JSON schemas using the zod-to-json-schema package and pass them directly. This ensures the LLM output conforms to your Zod schema, and you can then safely parse the response with schema.parse() for full type safety.

Should I use Zod or TypeScript interfaces for tool definitions?

Use both. Define your types with Zod schemas (which provide runtime validation) and infer TypeScript types from them with z.infer<typeof schema>. This gives you compile-time type checking from TypeScript and runtime validation from Zod. Never define types separately from schemas — they will drift apart over time.

Can I use TypeScript agent code with Python agent frameworks?

Not directly, but you can interoperate through HTTP APIs or message queues. Build your TypeScript agent as a service that exposes tool endpoints and a chat API, then call it from a Python orchestrator. Alternatively, use MCP (Model Context Protocol) servers — build your tools in TypeScript as MCP servers, and consume them from Python agents via the MCP client protocol.

How do I handle streaming responses in TypeScript?

Both the Anthropic and OpenAI TypeScript SDKs support streaming via async iterables. Use client.messages.stream() for Anthropic or client.chat.completions.create({ stream: true }) for OpenAI. Pipe the stream through a Server-Sent Events (SSE) response in your HTTP framework. TypeScript's type system helps here — the streaming response types are distinct from the non-streaming types, so you cannot accidentally treat a stream as a complete response.

Share this article
C

CallSphere Team

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.