LLMs love to call tools, but a mismatched signature can send your agent spiraling into nonsense. TypeScript 5.5 gives you a way to catch those mismatches before the model ever runs, turning a fragile plan‑act‑observe loop into a type‑checked contract. In this post we’ll see exactly how.
Why Tool‑Calling Needs Strong Types
When a large language model (LLM) decides to invoke a tool, it does so by emitting JSON that describes what to call and with which arguments. If the JSON does not match the shape that the tool’s SDK expects, the call will throw at runtime, and the LLM will have no way to recover because it never saw the error.
Think of an LLM as a tourist who only speaks English trying to order food in a restaurant where the menu is written in Japanese. If the waiter (your code) can translate the order before the kitchen sees it, the tourist never gets a cold dish. TypeScript’s type system is that translator: it checks the shape of the order before the kitchen (the real API) sees it.
In plain English: Strong types stop the LLM from handing the kitchen a plate it can’t cook.
The concrete problem
import { Anthropic } from '@anthropic-ai/sdk';
// The SDK expects a payload like this:
await client.messages.create({
model: 'claude-3-5-sonnet-20240620',
max_tokens: 1024,
tools: [{ name: 'search', description: 'Web search', input_schema: {/* … */} }],
});
If the LLM returns { tool: "search", args: "just a string" } instead of an object, the SDK will throw. Without a compile‑time guard, the error appears only after the request has been sent, wasting latency and possibly leaking sensitive data.
TypeScript 5.5 Inferred Type Predicates Refresher
A type predicate is a function whose return type tells TypeScript “if this function returns true, then the argument has this more specific type.” In earlier versions you had to write the predicate signature manually:
function isSearchTool(arg: unknown): arg is SearchToolPayload {
return typeof arg === 'object' && arg !== null && 'query' in arg;
}
TypeScript 5.5 introduced inferred type predicates: if the function body already performs a series of checks, the compiler can infer the narrowed type without you writing arg is … yourself. This makes guard functions much shorter and less error‑prone.
Simple analogy
Imagine a security guard who only lets people with a badge and a hat into a room. In the old system you had to write on the badge “this person is allowed”. With inferred predicates, the guard simply checks the badge and hat, and the system automatically knows the person is allowed—no extra paperwork.
Key takeaway: Inferred predicates let you write “check‑once” guard functions; TypeScript fills in the “this is now a …” part for you.
Example of inference
function looksLikeSearchTool(arg: unknown) {
// The series of checks below let TS infer the return type.
return (
typeof arg === 'object' &&
arg !== null &&
'query' in arg &&
typeof (arg as any).query === 'string'
);
}
// TS now knows that if the function returns true, `arg` is SearchToolPayload.
Modeling Claude Function Calls with TypeScript Types
Claude’s function‑calling API expects a JSON object that matches a schema you define. Let’s model that schema with TypeScript interfaces so the compiler can validate any LLM response.
// 1️⃣ The shape of the tool we expose to Claude.
interface SearchToolPayload {
/** The user's search query, e.g. "weather in Paris tomorrow" */
query: string;
}
/** The full tool description that Claude sees. */
const searchTool = {
name: 'search',
description: 'Perform a web search and return the top result.',
// The SDK wants a JSON schema; we give a minimal version.
input_schema: {
type: 'object',
properties: {
query: { type: 'string' },
},
required: ['query'],
},
} as const;
Guard function using inferred predicate
function isSearchPayload(arg: unknown) {
// TypeScript will infer that a true result means `arg` conforms to SearchToolPayload.
return (
typeof arg === 'object' &&
arg !== null &&
'query' in arg &&
typeof (arg as any).query === 'string'
);
}
When we later receive a JSON string from Claude, we can safely parse it and let the guard confirm the shape.
function parseToolResponse(json: string): SearchToolPayload | null {
let parsed: unknown;
try {
parsed = JSON.parse(json);
} catch {
// Bad JSON – nothing we can do.
return null;
}
// The guard narrows `parsed` to `SearchToolPayload` if true.
return isSearchPayload(parsed) ? (parsed as SearchToolPayload) : null;
}
Tip: Keep guard functions tiny and focused on structural checks; avoid business logic inside them.
Gotcha: filter(Boolean) and type narrowing
A common pattern is array.filter(Boolean) to drop undefined values. In TS 5.5 the result type is correctly narrowed to the non‑nullable element type, unless you’re compiling a monorepo without isolatedDeclarations. In that case the compiler may read stale declaration files and treat the filtered array as still possibly containing undefined, breaking later guards.
To avoid the silent issue:
// tsconfig.json
{
"compilerOptions": {
"isolatedDeclarations": true,
// other options…
}
}
In plain English: Turn on
isolatedDeclarationssofilter(Boolean)really tells the compiler “no more falsy values”.
Implementing the Plan‑Act‑Observe Loop
The plan‑act‑observe loop is a simple orchestrator:
- Plan – ask Claude what it wants to do next.
- Act – call the tool that Claude selected.
- Observe – feed the tool’s result back into Claude for the next step.
Below is a minimal tsx script that runs this loop without a separate build step. tsx (from the tsx npm package) executes TypeScript files directly.
// file: agent.tsx
import { Anthropic } from '@anthropic-ai/sdk';
// -------------------------------------------------
// 1️⃣ Configuration
// -------------------------------------------------
const client = new Anthropic({
// The SDK reads ANTHROPIC_API_KEY from the environment.
// No hard‑coded secrets in source code.
});
// -------------------------------------------------
// 2️⃣ Helper: send a message and ask Claude to pick a tool
// -------------------------------------------------
async function askClaude(userMessage: string) {
const response = await client.messages.create({
model: 'claude-3-5-sonnet-20240620',
max_tokens: 512,
messages: [{ role: 'user', content: userMessage }],
tools: [searchTool], // we expose only the search tool for this demo
});
// Claude returns a `tool_use` block when it wants to call a tool.
const toolBlock = response.content.find(
(c) => typeof c === 'object' && 'type' in c && c.type === 'tool_use'
) as { type: 'tool_use'; id: string; name: string; input: unknown } | undefined;
return toolBlock ?? null;
}
// -------------------------------------------------
// 3️⃣ Guard: ensure the tool payload matches our schema
// -------------------------------------------------
function isSearchToolBlock(
block: { type: 'tool_use'; name: string; input: unknown } | null
): block is { type: 'tool_use'; name: 'search'; input: SearchToolPayload } {
return (
block !== null &&
block.name === 'search' &&
isSearchPayload(block.input)
);
}
// -------------------------------------------------
// 4️⃣ The main loop
// -------------------------------------------------
async function runLoop(initialPrompt: string) {
let userMessage = initialPrompt;
for (let step = 0; step < 5; step++) {
// PLAN
const toolBlock = await askClaude(userMessage);
if (!toolBlock) {
console.log('Claude decided to stop.');
break;
}
// ACT – only proceed if the block passes our type guard
if (!isSearchToolBlock(toolBlock)) {
console.error('Received unexpected tool call:', toolBlock);
break;
}
const { query } = toolBlock.input; // `input` is now known to be SearchToolPayload
console.log(`🔎 Searching for: "${query}"`);
// Here we would call a real search API; we mock it for brevity.
const mockResult = `Top result for "${query}"`;
// OBSERVE – feed the result back to Claude
userMessage = `Tool result (id=${toolBlock.id}): ${mockResult}`;
}
}
// -------------------------------------------------
// 5️⃣ Kick it off
// -------------------------------------------------
runLoop('Find the latest news about TypeScript 5.5 features.')
.catch((e) => console.error('Unhandled error:', e));
What the script does, step by step
- Creates a client for the Anthropic SDK – the SDK knows how to speak HTTP to Claude.
-
askClaudesends a user message and tells Claude which tools are available. - The response may contain a
tool_useblock. We locate it withArray.find. -
isSearchToolBlockcombines two checks:- The block’s
nameis'search'. - The block’s
inputpasses theisSearchPayloadguard. Because of inferred predicates, the compiler now knowsinputisSearchToolPayload.
- The block’s
- If the guard passes, we simulate a search, then feed the mock result back into the next iteration.
- The loop stops after five steps or when Claude returns no tool call.
Helpful tip: Run the script with
npx tsx agent.tsx. Notsccompile step is needed, which keeps the feedback loop fast while you iterate on types.
Secondary service context – AIAgents
If you later move to a larger framework like AIAgents (a hypothetical orchestration SDK), the same guard pattern applies. The only difference is the shape of the tool descriptor object, which you would model with an interface just as we did for searchTool. The same gotcha about stale declaration files appears if the framework publishes its own .d.ts files without isolatedDeclarations.
Testing and Debugging with tsx
Because the type guards run before any network request, you can unit‑test them with plain TypeScript. Below is a tiny test suite that you can run with tsx as well.
// file: guard.test.tsx
import { strict as assert } from 'node:assert';
// Re‑import the guard from the agent file.
import { isSearchPayload } from './agent';
// ---------- Test cases ----------
const good = { query: 'hello world' };
const badMissing = { q: 'hello' };
const badType = { query: 123 };
assert.equal(isSearchPayload(good), true, 'valid payload should pass');
assert.equal(isSearchPayload(badMissing), false, 'missing field should fail');
assert.equal(isSearchPayload(badType), false, 'wrong type should fail');
console.log('✅ All guard tests passed');
Run it with:
npx tsx guard.test.tsx
If you add a new field to SearchToolPayload, the compiler will immediately warn you about any test that still uses the old shape—another safety net.
In plain English: Tests give you a second line of defense; type guards give you the first.
Debugging a mismatched signature
Suppose Claude returns { tool: "search", args: { query: 42 } }. The guard will return false. Because we log an error before breaking the loop, you’ll see:
Received unexpected tool call: { type: 'tool_use', name: 'search', input: { query: 42 } }
At this point you know the problem is type‑level, not network‑level, and you can adjust either the LLM prompt (to ask for a string) or the TypeScript definition (if the API really expects a number).
The Takeaway
Key points to remember
- Strong types protect the plan‑act‑observe loop from LLM hallucinations that would otherwise cause runtime crashes.
- Inferred type predicates introduced in TypeScript 5.5 let you write concise guard functions; the compiler does the heavy lifting of narrowing types.
- Modeling Claude’s function‑calling schema with interfaces creates a contract that both the SDK and the LLM must obey.
- Enabling
isolatedDeclarationsprevents stale.d.tsfiles from breakingfilter(Boolean)‑based narrowing in monorepos. -
tsxlets you run TypeScript scripts directly, making it easy to iterate on type‑checked agents without a build step. - Unit tests for guard functions give you a cheap, fast safety net that catches schema changes early.
By treating TypeScript not just as a compile‑time nicety but as a runtime guard, you turn a fragile conversational agent into a predictable, debuggable system. The next time your LLM tries to call a tool, the TypeScript compiler will be the first line of defense, catching mismatches before they ever hit the wire. Happy coding!
Transparency notice
This article was written with the help of an AI system — Groq (GPT OSS 120B).
Published: 2026-09-14 · Primary focus: TypeScript55
All code blocks are intended to be correct and runnable, but please verify them
against the TypeScript docs before using in production.Find an error? Drop a comment — corrections are always welcome.
Top comments (0)