Function Calling in TypeScript Without Losing Type Safety
A typed tool-calling loop in TypeScript: Zod schemas, a discriminated tool registry, safe argument parsing, streaming assembly and AbortController cancellation.
TypeScript gives you a compiler that can prove your tool handlers match their schemas, and most tool-calling code throws that away by typing arguments as any the moment they arrive from the model. The JSON is untrusted, so it has to be parsed at runtime — but parsed once, into a type the rest of the code can rely on.
What follows is a loop that keeps that property. The provider-agnostic explanation of the mechanism is in how tool calling actually works; this is the implementation detail specific to a typed codebase.
One schema, two consumers
Every tool needs a JSON Schema for the wire and a runtime validator for the response. Writing both by hand is how they diverge. Zod plus a schema converter gives you both from one declaration, and infers the handler argument type for free.
import { z } from 'zod'
import { zodToJsonSchema } from 'zod-to-json-schema'
const ReadFile = z.object({
path: z.string().describe('Path relative to the project root'),
maxBytes: z.number().int().min(1).max(1_000_000).default(8192),
})
type ReadFileArgs = z.infer<typeof ReadFile>
const readFileTool = {
type: 'function' as const,
function: {
name: 'read_file',
description: 'Read a UTF-8 text file from the project directory.',
parameters: zodToJsonSchema(ReadFile),
},
}
Check what the converter emits before you trust it. Providers vary in how strictly they enforce JSON Schema, and constructs like unions, optional-with-default and oneOf are the ones most likely to be rejected or silently ignored. The practical constraints are set out in what JSON Schema features tool calls actually support.
A registry the compiler can check
The dispatch step is where type safety usually dies, because the tool name is a string chosen by the model. Keep the mapping in one object and let inference flow through it, rather than casting at the call site.
type Tool<S extends z.ZodTypeAny> = {
schema: S
run: (args: z.infer<S>, ctx: Ctx) => Promise<unknown>
}
function defineTool<S extends z.ZodTypeAny>(t: Tool<S>) { return t }
const registry = {
read_file: defineTool({ schema: ReadFile, run: readFile }),
list_dir: defineTool({ schema: ListDir, run: listDir }),
}
The helper exists purely so each entry infers its own argument type instead of collapsing to a union. Now a handler that reads a property the schema does not define is a compile error, which is where you want to find it.
Parse, do not cast
The arguments field arrives as a JSON string. Two things can go wrong: it is not valid JSON, or it is valid JSON that does not match the schema. Handle both as data, not as exceptions.
async function dispatch(call: ToolCall, ctx: Ctx): Promise<string> {
const entry = registry[call.function.name as keyof typeof registry]
if (!entry) return JSON.stringify({ error: 'unknown_tool' })
let raw: unknown
try { raw = JSON.parse(call.function.arguments) }
catch { return JSON.stringify({ error: 'invalid_json' }) }
const parsed = entry.schema.safeParse(raw)
if (!parsed.success) {
return JSON.stringify({
error: 'invalid_arguments',
issues: parsed.error.issues.slice(0, 5),
})
}
try {
return JSON.stringify({ ok: true, result: await entry.run(parsed.data, ctx) })
} catch (e) {
return JSON.stringify({ error: 'tool_failed', detail: String(e).slice(0, 200) })
}
}
Every path returns a string the model can read. A validation failure handed back as a tool result is usually corrected on the next turn; a thrown error kills the conversation and leaves the caller with a stack trace instead of an answer.
The loop and its stop conditions
The loop sends messages, appends the assistant reply, executes any tool calls, appends the results keyed by tool_call_id, and repeats until the model returns content with no calls. Order matters: the assistant message must be in history before its results, and each result must reference the exact id it answers.
Give it three independent limits. A maximum number of turns stops a model that keeps calling tools. A cumulative token budget stops one that keeps calling cheap tools with enormous outputs. A wall-clock deadline stops everything else. Any single limit can be evaded by a loop that respects the other two, and the failure patterns are catalogued in recovering from agent errors.
Thread an AbortSignal through the whole thing. The OpenAI Node SDK accepts one per request, and Node fetch handlers expose a signal that fires when the client disconnects. Without it, a user closing a tab leaves your process paying for a completion nobody will read.
Assembling streamed tool calls
Streaming splits tool call arguments across many chunks. Each delta carries an index saying which call it extends, and you concatenate until the stream ends.
const acc = new Map<number, { id: string; name: string; args: string }>()
for await (const chunk of stream) {
for (const d of chunk.choices[0]?.delta?.tool_calls ?? []) {
const slot = acc.get(d.index) ?? { id: '', name: '', args: '' }
if (d.id) slot.id = d.id
if (d.function?.name) slot.name = d.function.name
if (d.function?.arguments) slot.args += d.function.arguments
acc.set(d.index, slot)
}
}
Do not attempt to parse the accumulated arguments until the stream has terminated properly. Partial JSON produces an error that looks like a model failure but is really a transport one, a distinction explored in handling SSE disconnects.
Parallel execution, carefully
Models frequently emit several calls in one message, and running them with Promise.all is a large latency win when they are independent reads. It is a correctness hazard when they are not.
The rule that holds up: run a batch concurrently only if every tool in it is declared read-only. Mark that on the registry entry rather than inferring it from the name. If any call mutates state, run the batch sequentially.
Whichever way you execute them, append the results in the order the calls appeared in the assistant message. Some providers validate that correspondence and reject out-of-order tool messages with a 400.
Testing the parts that break
Unit tests against the schema catch most drift. Assert that a known-good argument object parses, that a missing required field fails, and that the generated JSON Schema still contains the properties you expect after a Zod upgrade.
Then test the loop with a stubbed client that returns a fixed sequence of tool calls, including a malformed one and an unknown tool name. That test is fast, deterministic and catches the failures that only appear in production, which is more than can be said for any test that calls a real model.
Common questions
Do I need Zod, or is plain JSON Schema enough?
Plain schema works for the wire, but you still need runtime validation of what comes back. Deriving both from one declaration means a handler and its schema cannot drift apart without a compile error.
How should I type the arguments a model sends?
As unknown until validated. Cast nothing — parse the JSON, run it through the schema, and let inference give you the concrete type from the parse result.
Is it safe to run tool calls in parallel?
Only when every tool in the batch is read-only. Mark that explicitly on the registry entry; if anything in the batch mutates state, execute sequentially and keep the result order matching the call order.