When you wire function calling into a TypeScript service, the model can return malformed or unexpected arguments. Validating LLM tool arguments in TypeScript with Zod closes that gap by enforcing runtime types before your code acts on them.
Step 1: Model the tool contract with Zod
TypeScript’s static types are erased at compile time. The LLM emits a JSON string, not a typed object, so interface and type give you zero runtime protection. Write a Zod schema as the single source of truth for what your tool accepts.
import { z } from "zod";
export const SearchDocsArgs = z.object({
query: z.string().min(1).max(500),
topK: z.number().int().min(1).max(20).default(5),
filter: z
.object({
language: z.enum(["ts", "py", "go"]).optional(),
})
.optional(),
});
export type SearchDocsArgs = z.infer<typeof SearchDocsArgs>;
Why static types are not enough
A type SearchDocsArgs = { query: string } cannot reject query: 123 at runtime. Zod runs the check when the data crosses the trust boundary between the model and your system.
Coercion and defaults
The .default(5) on topK means Zod fills the value if the model omits it. This prevents unnecessary validation failures on optional numeric fields and keeps your execution code free of fallback logic. If you need coercion from strings, use z.coerce.number() deliberately—but prefer clean schemas so the model learns the correct type.
Step 2: Derive the model-facing JSON schema
OpenAI-compatible chat completions expect a JSON Schema fragment in the tools array. Hand-writing it duplicates the Zod definition and inevitably drifts. Use zod-to-json-schema to generate it from the same schema.
import { zodToJsonSchema } from "zod-to-json-schema";
const jsonSchema = zodToJsonSchema(SearchDocsArgs, "SearchDocsArgs");
const tools = [
{
type: "function",
function: {
name: "search_docs",
parameters: jsonSchema,
},
},
];
Avoid dual sources of truth
If you maintain a separate JSON Schema file, a schema change requires editing two places. Generating from Zod guarantees the model and your validator see the identical shape.
Trim descriptions for token efficiency
Every character in description costs tokens on each request. Keep tool and parameter descriptions terse but unambiguous. Zod lets you attach descriptions via z.string().describe("...") if you want them inline.
Step 3: Send the tool definition to the model
Construct a standard chat completion request. Point it at any OpenAI-compatible endpoint. If you route through n4n.ai, the same request hits one endpoint that fronts 240+ models and falls back automatically when a provider is degraded.
const res = await fetch("https://api.n4n.ai/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.LLM_API_KEY}`,
},
body: JSON.stringify({
model: "gpt-4o-mini",
messages: [
{ role: "system", content: "You help engineers navigate docs." },
{ role: "user", content: "Find TypeScript Zod examples." },
],
tools,
tool_choice: "auto",
}),
});
const data = await res.json();
Tool choice modes
tool_choice: "auto" lets the model decide. For deterministic pipelines, set tool_choice: { type: "function", function: { name: "search_docs" } } to force the call. Forcing is useful in tests and strict workflows.
Step 4: Validate the returned arguments at runtime
The arguments arrive as a JSON string inside tool_calls. Parse the string, then run it through SearchDocsArgs.parse. This step is the core of validating LLM tool arguments in TypeScript with Zod.
const toolCall = data.choices[0].message.tool_calls?.[0];
if (!toolCall || toolCall.function.name !== "search_docs") {
throw new Error("Expected search_docs tool call");
}
let args: SearchDocsArgs;
try {
const raw = JSON.parse(toolCall.function.arguments);
args = SearchDocsArgs.parse(raw);
} catch (err) {
if (err instanceof z.ZodError) {
console.error("Validation failed:", err.issues);
}
throw err;
}
safeParse vs parse
Use SearchDocsArgs.safeParse(raw) if you want a result object instead of a throw:
const result = SearchDocsArgs.safeParse(raw);
if (!result.success) {
// result.error.issues available
}
In a hot path, safeParse avoids exception overhead, but parse is fine for low-frequency tool calls.
Inspecting issues
err.issues is an array of { path, message, code }. Feed these back to the model (see Step 6) so it can correct the arguments rather than your code crashing.
Step 5: Execute the function with confidence
Because args is now typed, your implementation gets full editor support and compile-time safety.
async function searchDocs(args: SearchDocsArgs) {
const { query, topK, filter } = args;
console.log(`Searching ${topK} docs for "${query}"`);
return [{ title: "Zod guide", url: "/docs/zod" }];
}
const result = await searchDocs(args);
Typed side effects
If searchDocs hits a database, the query builder now knows topK is a number and filter?.language is a union. No as any casts required. This is where the upfront Zod work pays off across the codebase.
Step 6: Close the loop on validation failures
A 500 on bad arguments wastes a round trip. Return the Zod issues to the model as a tool result and let it self-correct. This pattern makes validating LLM tool arguments in TypeScript with Zod part of the control loop.
if (err instanceof z.ZodError) {
const feedback = {
role: "tool",
tool_call_id: toolCall.id,
content: JSON.stringify({
error: "invalid_arguments",
issues: err.issues,
}),
};
// send `feedback` plus prior messages back to the model
}
When to fail hard
Not every error is recoverable. If the model repeatedly sends query as an object after two corrections, abort and surface a fallback response to the user. Track retry counts in your orchestration layer.
Step 7: Verify the full pipeline
Write a small script that exercises the flow against a mock or real model. Use tsx to run TypeScript directly without a build step.
npm install zod zod-to-json-schema tsx
npx tsx validate-tool.ts
Mock the model response
Inside validate-tool.ts, stub a known tool call to test validation independent of network:
const data = {
choices: [
{
message: {
tool_calls: [
{
id: "call_1",
function: {
name: "search_docs",
arguments: '{"query":"zod","topK":3}',
},
},
],
},
},
],
};
Success criteria
The script should log Searching 3 docs for "zod" and exit without throwing. Then change the mock to {"query":"","topK":99}: Zod should reject it, print issues, and the catch block should fire. That confirms the validator blocks bad input before execution.
Practical notes for production
Strict mode and unknown keys
Add .strict() to your object schemas to reject unknown keys the model hallucinates. If you expect extensibility, use z.record(z.unknown()) at the boundary and narrow inside a second schema.
Versioning your schemas
Prefix tool names with a version (search_docs_v1). When you change a Zod schema in a breaking way, ship a new version and keep the old one until clients migrate. The generated JSON Schema should include the version in the title.
Determinism for cached definitions
Some gateways cache tool definitions via provider cache-control hints. Ensure your schema generation is deterministic—no random keys, no Date.now() in descriptions—so the cached blob matches across requests. Validating LLM tool arguments in TypeScript with Zod only stays cheap if the schema itself is stable.
Zod turns a brittle string parse into a typed, self-correcting interface. Ship the schema, generate the tool definition, validate on every call, and let the model fix its own mistakes.