n4nAI

Type-safe function calling in Node.js with Zod schemas

Build type-safe function calling in Node.js with Zod: convert schemas to JSON Schema, validate LLM tool calls, and run a reliable agent loop.

n4n Team4 min read845 words

Audio narration

Coming soon — every post will get a voice note here.

Getting model-generated tool calls to match your runtime code is where most agent prototypes break. Building type-safe function calling nodejs zod pipelines ensures the arguments an LLM sends are validated against the exact shape your functions expect, catching mismatches before they hit your database. This guide shows a complete, runnable pattern from schema definition to validated execution.

Step 1: Scaffold a TypeScript Node.js project

Set up a minimal ESM TypeScript project. We’ll use tsx to run directly without a build step.

mkdir zod-fn-call && cd zod-fn-call
npm init -y
npm install zod zod-to-json-schema openai
npm install -D typescript tsx @types/node

Create tsconfig.json with strict mode enabled—non-negotiable for type-safe function calling nodejs zod work:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["*.ts"]
}

Add a script to package.json: "start": "tsx index.ts".

Step 2: Define tool schemas with Zod

Every tool your model can call should start as a Zod schema. This is the single source of truth for both runtime validation and TypeScript types. Avoid hand-writing JSON Schema and separate TypeScript interfaces; that duplication is how drift happens.

import { z } from 'zod';

export const GetWeatherSchema = z.object({
  city: z.string().min(1).describe('City name, e.g. "Berlin"'),
  unit: z.enum(['celsius', 'fahrenheit']).describe('Temperature unit'),
});

export const SearchDocsSchema = z.object({
  query: z.string().min(3),
  maxResults: z.number().int().positive().default(5),
  filters: z.object({
    tag: z.string().optional(),
    since: z.string().datetime().optional(),
  }).optional(),
});

export type GetWeatherArgs = z.infer<typeof GetWeatherSchema>;
export type SearchDocsArgs = z.infer<typeof SearchDocsSchema>;

The .describe() calls are not cosmetic—they become the description fields in the JSON Schema sent to the model, which directly impacts call accuracy. Nested objects like filters work natively; Zod infers the full recursive type so your executor gets autocomplete on args.filters?.tag.

Why Zod instead of raw types

A plain TypeScript interface gives you compile-time checks but zero runtime guarantee. The model output is a string from an API. Zod closes that gap: the same schema validates the foreign input and produces the typed object. If you later add a field, you change one schema, not a type, a validator, and a JSON Schema.

Step 3: Compile Zod to JSON Schema

OpenAI-compatible APIs expect tools defined in JSON Schema. The zod-to-json-schema package handles the conversion, but you must set strict mode to match the API’s expectations for additional properties.

import { zodToJsonSchema } from 'zod-to-json-schema';

function toolDefinition(name: string, schema: z.ZodTypeAny, description: string) {
  return {
    type: 'function',
    function: {
      name,
      description,
      parameters: zodToJsonSchema(schema, { strict: true, target: 'openAi' }),
    },
  };
}

const tools = [
  toolDefinition('get_weather', GetWeatherSchema, 'Fetch current weather for a city'),
  toolDefinition('search_docs', SearchDocsSchema, 'Search internal documentation'),
];

Note: target: 'openAi' strips Zod-specific extensions that the endpoint would reject. If you skip this, you’ll get 400s on unknown keywords like default appearing in the wrong place. The strict: true option sets additionalProperties: false, which forces the model to stay inside your defined shape.

Step 4: Send tools to an OpenAI-compatible endpoint

We use the openai SDK pointed at any compatible base URL. If you route through n4n.ai, its OpenAI-compatible endpoint fronts 240+ models with automatic fallback when a provider is degraded, so the same tools payload works without writing your own retry layer.

import OpenAI from 'openai';

const client = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
  baseURL: process.env.OPENAI_BASE_URL ?? 'https://api.openai.com/v1',
});

async function chatWithTools(messages: OpenAI.Chat.ChatCompletionMessageParam[]) {
  return client.chat.completions.create({
    model: process.env.MODEL ?? 'gpt-4o-mini',
    messages,
    tools,
    tool_choice: 'auto',
  });
}

Keep tool_choice: 'auto' unless you have a reason to force a specific tool. Forcing it prematurely is a common bug in type-safe function calling nodejs zod loops because the model never gets to decide if a tool is even relevant.

Step 5: Validate the model’s tool call with Zod

The response contains tool_calls on the assistant message. Each has a function.arguments string that must be parsed and validated. Never trust it.

import type { ChatCompletionMessage } from 'openai/resources/chat';

const schemaMap = {
  get_weather: GetWeatherSchema,
  search_docs: SearchDocsSchema,
} as const;

function parseToolCall(call: ChatCompletionMessage.ToolCall) {
  const schema = schemaMap[call.function.name as keyof typeof schemaMap];
  if (!schema) throw new Error(`Unknown tool: ${call.function.name}`);
  const raw = JSON.parse(call.function.arguments);
  return schema.parse(raw); // throws on mismatch
}

schema.parse returns a strongly typed object. If the model emits unit: 'kelvin', Zod throws a ZodError before your function ever runs. That’s the core win of type-safe function calling nodejs zod: invalid arguments become catchable errors, not undefined crashes.

When validation fails, return the error to the model so it can self-correct:

catch (err) {
  const msg = err instanceof z.ZodError ? err.issues.map(i => i.message).join('; ') : (err as Error).message;
  messages.push({ role: 'tool', tool_call_id: call.id, content: JSON.stringify({ error: msg }) });
}

Step 6: Execute the function and return results

Write thin wrappers that accept the validated args. The types flow automatically from z.infer. Keep returns JSON-serializable; the model only sees strings.

async function executeTool(name: string, args: unknown) {
  switch (name) {
    case 'get_weather': {
      const { city, unit } = args as GetWeatherArgs;
      // pretend API call
      return { temp: 22, unit, city };
    }
    case 'search_docs': {
      const { query, maxResults, filters } = args as SearchDocsArgs;
      return { hits: [{ title: 'Zod guide', query, maxResults, filters }] };
    }
    default:
      throw new Error(`No handler for ${name}`);
  }
}

Then close the loop: append the assistant message, append a tool result message, and call again.

async function runAgent(userInput: string) {
  const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [
    { role: 'user', content: userInput },
  ];

  const res = await chatWithTools(messages);
  const assistant = res.choices[0].message;
  messages.push(assistant);

  if (!assistant.tool_calls) return assistant.content;

  for (const call of assistant.tool_calls) {
    try {
      const validated = parseToolCall(call);
      const result = await executeTool(call.function.name, validated);
      messages.push({
        role: 'tool',
        tool_call_id: call.id,
        content: JSON.stringify(result),
      });
    } catch (err) {
      const msg = err instanceof z.ZodError ? err.issues.map(i => i.message).join('; ') : (err as Error).message;
      messages.push({
        role: 'tool',
        tool_call_id: call.id,
        content: JSON.stringify({ error: msg }),
      });
    }
  }

  const final = await chatWithTools(messages);
  return final.choices[0].message.content;
}

Step 7: Handle streaming and partial tool calls

Production loops often stream. The OpenAI SDK emits tool_calls incrementally; you must accumulate function.arguments across chunks before parsing. Zod validation still happens only on the complete string.

let acc = '';
stream.on('chunk', (chunk) => {
  const delta = chunk.choices[0]?.delta?.tool_calls?.[0]?.function?.arguments;
  if (delta) acc += delta;
});
stream.on('end', () => {
  const parsed = GetWeatherSchema.parse(JSON.parse(acc));
});

If you skip accumulation, you’ll feed half-formed JSON to JSON.parse and waste a validation cycle.

Step 8: Verify success

Run the script with a clear trigger:

OPENAI_API_KEY=sk-... npx tsx index.ts

Add a quick test harness at the bottom of index.ts:

runAgent('What is the weather in Berlin in celsius?')
  .then(console.log)
  .catch(console.error);

Success criteria:

  1. The process prints a natural-language answer referencing Berlin and 22 celsius.
  2. Temporarily change GetWeatherSchema to unit: z.enum(['celsius']) and restart—if the model returns fahrenheit, Zod throws and the tool message contains a ZodError. That proves validation runs.
  3. Run tsc --noEmit to confirm the inferred types compile under strict mode.

If you meet all three, your type-safe function calling nodejs zod pipeline is correctly validating model output before execution.

Pitfalls we hit in production

  • Default values: Zod .default() works in validation but the model may omit the field; JSON Schema generation must not mark it required. zod-to-json-schema handles this, but double-check the emitted schema with a console.log.
  • Description starvation: A tool with no .describe() yields vague model calls. Spend time on schema descriptions; they are the prompt.
  • Strict mode mismatch: If your JSON Schema has additionalProperties: false but you send extra fields from a previous schema version, validation fails. Version your tools.
  • Error feedback loop: When Zod rejects arguments, return the error as a tool message and let the model retry. Do not silently drop the call—silence produces confused follow-ups.
  • Enum drift: Changing an enum value in Zod without updating any cached system prompt can cause the model to use the old string. Treat schemas as deployed contracts.

Following these steps gives you a Node.js agent where every tool invocation is typed, validated, and executed against a single Zod source of truth. That is the bar for shipping LLM features that do not page you at 3am.

Tagstypescriptnodejszodfunction-calling

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All function calling in typescript/node.js posts →