When you wire LLM tool calls into a TypeScript service, the risk isn’t the network round-trip—it’s the untyped gap between the model’s emitted JSON and your function signatures. Solid typescript openai function calling types close that gap so a renamed parameter or missing field fails at compile time, not in a 3 a.m. page. This guide shows how to define tools once and infer both the API schema and the local handler types from that single source, then run a typed dispatch loop end to end.
Step 1: Install dependencies and create the client
Use the official SDK. It ships the request/response types you want for chat completions and tools.
npm install openai json-schema-to-ts
import OpenAI from 'openai';
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
// If you route through n4n.ai, the same TypeScript types apply because its
// endpoint is OpenAI-compatible:
// const client = new OpenAI({ apiKey: process.env.N4N_KEY, baseURL: 'https://api.n4n.ai/v1' });
Keep the client thin. All tool-specific typing lives in your own module.
Step 2: Define tool schemas with const assertions
Write the JSON Schema for each tool exactly as the API expects. Use as const so TypeScript preserves literal strings like 'get_weather' and the enum values.
const toolSchemas = [
{
type: 'function',
function: {
name: 'get_weather',
parameters: {
type: 'object',
properties: {
location: { type: 'string' },
unit: { type: 'string', enum: ['c', 'f'] },
},
required: ['location'],
additionalProperties: false,
},
},
},
] as const;
type ToolSchema = (typeof toolSchemas)[number];
The as const makes unit’s enum a tuple ['c', 'f'] rather than string[]. That matters when deriving types.
Step 3: Derive TypeScript types from the schemas
The OpenAI API consumes JSON Schema, but your handlers need TypeScript interfaces. Use json-schema-to-ts to map the schema to a static type without hand-writing interfaces.
import type { FromSchema } from 'json-schema-to-ts';
type GetWeatherSchema = (typeof toolSchemas)[0]['function']['parameters'];
type GetWeatherArgs = FromSchema<GetWeatherSchema>;
// Equivalent to:
// type GetWeatherArgs = {
// location: string;
// unit?: 'c' | 'f';
// };
Now typescript openai function calling types are generated, not duplicated. If you add a parameter to the schema, the handler argument type changes automatically.
For multiple tools, map them programmatically:
type ToolArgs = {
[K in ToolSchema['function']['name']]: FromSchema<
Extract<ToolSchema, { function: { name: K } }>['function']['parameters']
>;
};
This yields a dictionary ToolArgs = { get_weather: GetWeatherArgs } (and more as you add entries).
Step 4: Build a typed handler registry
Define the local functions with the inferred argument types. Use a registry keyed by the exact tool name.
const handlers: {
[K in keyof ToolArgs]: (args: ToolArgs[K]) => unknown;
} = {
get_weather: (args) => {
// args is GetWeatherArgs here
const u = args.unit ?? 'c';
return { location: args.location, temp: 21, unit: u };
},
};
The mapped type forces every tool name in toolSchemas to have a matching handler with the correct signature. Forget one and tsc errors.
Step 5: Cast schemas for the SDK and send the request
The SDK expects ChatCompletionTool[]. Your as const object is narrower, so cast it once at the boundary.
import type { ChatCompletionTool } from 'openai/resources/chat/completions';
const tools = toolSchemas as unknown as ChatCompletionTool[];
const completion = await client.chat.completions.create({
model: 'gpt-4o-mini',
messages: [{ role: 'user', content: 'What is the weather in Berlin?' }],
tools,
tool_choice: 'auto',
});
The response message may contain tool_calls. Type that field as OpenAI.ChatCompletionMessageToolCall[].
Step 6: Dispatch tool calls with type narrowing
Iterate the tool calls and route to the registry. Parse arguments, then narrow by name.
const msg = completion.choices[0].message;
const toolCalls = msg.tool_calls ?? [];
for (const call of toolCalls) {
if (call.type !== 'function') continue;
const name = call.function.name as keyof ToolArgs;
const raw = JSON.parse(call.function.arguments) as unknown;
// Runtime guard (see Step 7) goes here
const args = raw as ToolArgs[typeof name];
const result = handlers[name](args);
// Send result back to the model
await client.chat.completions.create({
model: 'gpt-4o-mini',
messages: [
{ role: 'user', content: 'What is the weather in Berlin?' },
msg,
{
role: 'tool',
tool_call_id: call.id,
content: JSON.stringify(result),
},
],
});
}
Because handlers is keyed by keyof ToolArgs, the call is type-checked. The cast as ToolArgs[typeof name] is safe only after validation.
Step 7: Add runtime validation
Types vanish at runtime. The model can emit malformed JSON or omit a required field. Add a minimal predicate or use Zod.
function isGetWeatherArgs(x: unknown): x is GetWeatherArgs {
if (typeof x !== 'object' || x === null) return false;
const o = x as Record<string, unknown>;
return typeof o['location'] === 'string' &&
(o['unit'] === undefined || o['unit'] === 'c' || o['unit'] === 'f');
}
In the loop:
if (name === 'get_weather' && !isGetWeatherArgs(raw)) {
throw new Error(`Invalid args for ${name}`);
}
For larger surfaces, generate Zod schemas from the same JSON Schema with json-schema-to-zod to avoid drift.
Step 8: Verify the pipeline
Write a small script run.ts that exercises the flow without network flakiness by stubbing the client. Or run against the live API with a test key.
// run.ts
async function main() {
const c = await client.chat.completions.create({
model: 'gpt-4o-mini',
messages: [{ role: 'user', content: 'Weather in Paris?' }],
tools,
});
const tc = c.choices[0].message.tool_calls ?? [];
console.assert(tc.length === 1, 'expected one tool call');
console.assert(tc[0].function.name === 'get_weather', 'expected get_weather');
const args = JSON.parse(tc[0].function.arguments);
console.assert(typeof args.location === 'string', 'location must be string');
console.log('OK', args);
}
main();
Run with npx ts-node run.ts. Success means the script prints OK { location: 'Paris' } (or similar) and the TypeScript compiler passes with tsc --noEmit. If you change required in the schema but not the handler, tsc fails before you run anything.
Pitfalls to avoid
- Double-defining schemas. If you write both a JSON Schema and a hand-written
interface, they will diverge. Generate one from the other. - Loose
anyon tools. Castingtools as anythrows away the literal names; useas unknown as ChatCompletionTool[]at the edge only. - Ignoring
additionalProperties. OpenAI rejects extra fields. SetadditionalProperties: falsein the schema and mirror it in your runtime check. - Enum widening. Without
as const,'c' | 'f'becomesstring, and the model may pass'kelvin'. Keep the const.
Strong typescript openai function calling types are not boilerplate. They are the contract that lets you refactor tools without reading every prompt. Do it once per project and the compiler becomes your integration test.