TypeScript's `satisfies` Operator Is The Best Thing To Happen To AI Agent Config

typescript dev.to

I wasted three hours last month chasing a runtime bug in my AI agent config. The tool definitions looked fine. TypeScript wasn't complaining. Then at 2 AM the agent called a tool that didn't exist.

The problem: I was using as const where I should have been using satisfies.

Here's what changed everything.

The Problem With as const for Agent Config

When you define Claude tool schemas, you want two things simultaneously:

  1. TypeScript should enforce the shape is correct
  2. TypeScript should infer the specific literal values (not just string)

as const gives you (2) but not (1). Type assertions give you (1) but lose (1). Until TypeScript 4.9, you had to pick.

// as const — infers literals but no shape validation
const tools = [
  {
    name: "search_web",
    description: "Search the internet",
    input_schema: {
      type: "object",
      properties: {
        query: { type: "string" }
      },
      required: ["query"]
    }
  }
] as const;

// Type assertion — validates shape but widens literals
const tools: Tool[] = [
  {
    name: "search_web",
    // TypeScript accepts this but loses literal inference
    // tools[0].name is now `string`, not `"search_web"`
  }
];
Enter fullscreen mode Exit fullscreen mode

Why does losing literal inference matter? Because you can't write a discriminated union over your tool names. You can't have handleToolCall that TypeScript knows is exhaustive. You get runtime errors instead of compile-time errors.

The Fix: satisfies

import Anthropic from "@anthropic-ai/sdk";

type Tool = Anthropic.Messages.Tool;

const TOOLS = [
  {
    name: "search_web",
    description: "Search the internet for current information",
    input_schema: {
      type: "object" as const,
      properties: {
        query: {
          type: "string" as const,
          description: "The search query"
        }
      },
      required: ["query"]
    }
  },
  {
    name: "read_file",
    description: "Read a file from the filesystem",
    input_schema: {
      type: "object" as const,
      properties: {
        path: {
          type: "string" as const,
          description: "Absolute path to the file"
        }
      },
      required: ["path"]
    }
  }
] satisfies Tool[];

// Now TOOLS[0].name is literally "search_web", not string
type ToolName = typeof TOOLS[number]["name"];
// => "search_web" | "read_file"
Enter fullscreen mode Exit fullscreen mode

With satisfies, TypeScript validates that every element matches Tool and preserves the literal types. You get both.

Building a Type-Safe Tool Dispatcher

This is where it pays off. You can now write a handleTool function that TypeScript proves handles every case:

type ToolInput<T extends ToolName> = T extends "search_web"
  ? { query: string }
  : T extends "read_file"
  ? { path: string }
  : never;

async function handleTool<T extends ToolName>(
  name: T,
  input: ToolInput<T>
): Promise<string> {
  switch (name) {
    case "search_web":
      // TypeScript knows input.query is string here
      return await searchWeb(input.query);
    case "read_file":
      // TypeScript knows input.path is string here
      return await readFile(input.path);
    // If you add a new tool to TOOLS and forget to handle it here,
    // TypeScript will error at compile time
  }
}
Enter fullscreen mode Exit fullscreen mode

Add a new tool to TOOLS and forget to handle it in handleTool? TypeScript tells you immediately. No more 2 AM runtime surprises.

The Full Agent Loop

Here's how I wire this into a production Claude agent:

import Anthropic from "@anthropic-ai/sdk";

const client = new Anthropic();

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

  while (true) {
    const response = await client.messages.create({
      model: "claude-opus-4-6",
      max_tokens: 4096,
      tools: TOOLS, // fully typed, validates against Tool[]
      messages
    });

    if (response.stop_reason === "end_turn") {
      const textBlock = response.content.find(b => b.type === "text");
      return textBlock?.text ?? "";
    }

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

      messages.push({ role: "assistant", content: response.content });

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

      for (const toolUse of toolUses) {
        // Cast is safe because Claude only returns tool names we gave it
        const name = toolUse.name as ToolName;
        const result = await handleTool(name, toolUse.input as ToolInput<typeof name>);
        toolResults.push({
          type: "tool_result",
          tool_use_id: toolUse.id,
          content: result
        });
      }

      messages.push({ role: "user", content: toolResults });
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Three More Places satisfies Saves You

1. Model configuration

type ModelId = "claude-opus-4-6" | "claude-sonnet-4-6" | "claude-haiku-4-5";

interface AgentConfig {
  model: ModelId;
  maxTokens: number;
  temperature?: number;
}

const AGENT_CONFIGS = {
  research: {
    model: "claude-opus-4-6",
    maxTokens: 8192,
    temperature: 0.3
  },
  summarizer: {
    model: "claude-haiku-4-5",
    maxTokens: 1024
  },
  coder: {
    model: "claude-sonnet-4-6",
    maxTokens: 4096,
    temperature: 0.1
  }
} satisfies Record<string, AgentConfig>;

// AGENT_CONFIGS.research.model is "claude-opus-4-6", not ModelId
Enter fullscreen mode Exit fullscreen mode

2. System prompt registry

const SYSTEM_PROMPTS = {
  agent: "You are an autonomous AI agent...",
  reviewer: "You are a code reviewer...",
  planner: "You are a strategic planner..."
} satisfies Record<string, string>;

type PromptKey = keyof typeof SYSTEM_PROMPTS;
// => "agent" | "reviewer" | "planner"
Enter fullscreen mode Exit fullscreen mode

3. MCP server definitions

interface McpServer {
  command: string;
  args: string[];
  env?: Record<string, string>;
}

const MCP_SERVERS = {
  filesystem: {
    command: "npx",
    args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
  },
  github: {
    command: "npx",
    args: ["-y", "@modelcontextprotocol/server-github"],
    env: { GITHUB_PERSONAL_ACCESS_TOKEN: process.env.GITHUB_TOKEN! }
  }
} satisfies Record<string, McpServer>;
Enter fullscreen mode Exit fullscreen mode

Quick Reference

Pattern Use when
as const You only need literal inference, no shape validation
Type annotation You need shape validation, don't care about literals
satisfies You need both — production agent config, tool definitions, any enum-like registry

The Bottom Line

If you're building AI agents in TypeScript and still using as const for your tool definitions, you're leaving compile-time safety on the table. The satisfies operator is a one-word fix that prevents entire categories of runtime errors.

I use this pattern across the full whoffagents.com automation stack — 20+ tools, 6 agents, zero runtime tool-name errors since switching.

The type system is smarter than you think. Let it do the work.


Building autonomous AI agents? Check out whoffagents.com for production-ready agent templates and tooling.

Source: dev.to

arrow_back Back to Tutorials