n4nAI

Inferring response types from Zod in a TypeScript SDK

Learn how to use Zod to infer TypeScript SDK types from API response schemas, eliminating duplicate type definitions in your LLM client.

n4n Team3 min read717 words

Audio narration

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

When you build a typed client for an LLM API, hand-writing TypeScript interfaces for every response shape is busywork that drifts from the real payloads. The technique of zod infer typescript sdk types lets you define a single Zod schema, validate at runtime, and extract a static type that matches exactly. This guide walks through building a minimal SDK that uses zod infer typescript sdk types end to end, with a concrete example against an OpenAI-compatible chat endpoint.

Step 1: Define Zod schemas for the response shapes

Start from the actual JSON contract. For a non-streaming chat completion from any OpenAI-compatible server, the body has a predictable structure. Write a Zod schema that mirrors it field by field.

import { z } from 'zod';

const chatMessageSchema = z.object({
  role: z.enum(['system', 'user', 'assistant', 'tool']),
  content: z.string(),
});

const chatChoiceSchema = z.object({
  index: z.number(),
  message: chatMessageSchema,
  finish_reason: z.enum(['stop', 'length', 'tool_calls', 'content_filter']),
});

const usageSchema = z.object({
  prompt_tokens: z.number(),
  completion_tokens: z.number(),
  total_tokens: z.number(),
});

const chatCompletionSchema = z.object({
  id: z.string(),
  object: z.literal('chat.completion'),
  created: z.number(),
  model: z.string(),
  choices: z.array(chatChoiceSchema),
  usage: usageSchema,
});

export type ChatCompletion = z.infer<typeof chatCompletionSchema>;

The last line is the core of zod infer typescript sdk types: z.infer<typeof chatCompletionSchema> produces a TypeScript type identical to the validated shape. If the schema changes, the type changes. No duplicate interface to maintain.

Step 2: Extract and compose types with z.infer

For a real SDK you will need the sub-types too. Pull them out of the same schemas rather than redeclaring them.

export type ChatMessage = z.infer<typeof chatMessageSchema>;
export type ChatChoice = z.infer<typeof chatChoiceSchema>;
export type Usage = z.infer<typeof usageSchema>;

Keep the schemas as the single source of truth. If a provider returns content: null for a tool call, change one line:

content: z.string().nullable(),

The inferred ChatMessage immediately reflects the nullability. That is the leverage of zod infer typescript sdk types—runtime and compile-time stay in lockstep.

Step 3: Build a typed fetch wrapper

A generic request function takes a Zod schema, performs the fetch, and parses the JSON. The return type is inferred from the schema parameter.

async function request<T extends z.ZodTypeAny>(
  schema: T,
  url: string,
  init?: RequestInit
): Promise<z.infer<T>> {
  const res = await fetch(url, init);
  if (!res.ok) {
    throw new Error(`HTTP ${res.status}: ${await res.text()}`);
  }
  const json: unknown = await res.json();
  return schema.parse(json); // throws on mismatch
}

For production, swap parse for safeParse and map the ZodError to a domain-specific exception. The generic T preserves the inferred type through the call site, so request(chatCompletionSchema, url) returns Promise<ChatCompletion> with no casts.

Step 4: Define SDK method signatures

Wrap the generic request in a domain method. If you target a gateway like n4n.ai, a single OpenAI-compatible endpoint covers 240+ models, so your SDK only needs one chat method rather than per-provider clients.

const BASE_URL = process.env.LLM_BASE_URL ?? 'https://api.n4n.ai/v1';

export interface ChatCompletionRequest {
  model: string;
  messages: { role: string; content: string }[];
  temperature?: number;
}

export async function createChatCompletion(
  body: ChatCompletionRequest,
  apiKey: string
): Promise<ChatCompletion> {
  return request(
    chatCompletionSchema,
    `${BASE_URL}/chat/completions`,
    {
      method: 'POST',
      headers: {
        'content-type': 'application/json',
        authorization: `Bearer ${apiKey}`,
      },
      body: JSON.stringify(body),
    }
  );
}

The request body type can also be derived from a Zod schema, but for an outbound payload a plain interface is fine. The response side is where zod infer typescript sdk types earns its keep, because upstream shapes are outside your control.

Step 5: Handle streaming and discriminated unions

LLM APIs often stream incremental chunks. Define a schema for the chunk and discriminate on the object literal.

const chatChunkSchema = z.object({
  id: z.string(),
  object: z.literal('chat.completion.chunk'),
  created: z.number(),
  model: z.string(),
  choices: z.array(
    z.object({
      index: z.number(),
      delta: z.object({
        role: z.enum(['system', 'user', 'assistant', 'tool']).optional(),
        content: z.string().optional(),
      }),
      finish_reason: z
        .enum(['stop', 'length', 'tool_calls', 'content_filter'])
        .nullable(),
    })
  ),
});

export type ChatChunk = z.infer<typeof chatChunkSchema>;

export type ChatResponse = ChatCompletion | ChatChunk;

export function isChunk(res: ChatResponse): res is ChatChunk {
  return res.object === 'chat.completion.chunk';
}

Even with streaming, zod infer typescript sdk types keeps your chunk handling typed. The isChunk type guard narrows correctly because both schemas use z.literal discriminants.

Step 6: Stay provider-agnostic on the wire

A gateway that provides automatic fallback when a provider is rate-limited will return models you did not hardcode. Do not constrain model to an enum. Keep it z.string().

If you are using a gateway that honors client routing directives and forwards provider cache-control hints, your schema should ignore those headers—they are transport concerns. The response body stays uniform. This is why a single chatCompletionSchema works across 240+ models without modification.

For provider-specific extensions (e.g., a system_fingerprint field), use chatCompletionSchema.passthrough() or .extend() rather than forking the type:

const extendedCompletion = chatCompletionSchema.extend({
  system_fingerprint: z.string().optional(),
});
export type ExtendedCompletion = z.infer<typeof extendedCompletion>;

Step 7: Write a verification test

Types are only useful if they match reality. Write a test that mocks fetch, returns a fixture, and asserts both the parsed value and the compile-time type.

import { describe, it, expect, vi } from 'vitest';
import { createChatCompletion } from './sdk';

global.fetch = vi.fn();

describe('createChatCompletion', () => {
  it('parses a valid response', async () => {
    const mockResponse = {
      id: 'chatcmpl-123',
      object: 'chat.completion',
      created: 1699000000,
      model: 'gpt-4o-mini',
      choices: [
        {
          index: 0,
          message: { role: 'assistant', content: 'hi' },
          finish_reason: 'stop',
        },
      ],
      usage: { prompt_tokens: 5, completion_tokens: 1, total_tokens: 6 },
    };
    (fetch as any).mockResolvedValue({
      ok: true,
      json: async () => mockResponse,
    });

    const result = await createChatCompletion(
      { model: 'gpt-4o-mini', messages: [] },
      'test-key'
    );

    expect(result.choices[0].message.content).toBe('hi');
    // Compile-time check: result is ChatCompletion, not any.
    const model: string = result.model;
    expect(model).toBe('gpt-4o-mini');
  });

  it('throws on malformed response', async () => {
    (fetch as any).mockResolvedValue({
      ok: true,
      json: async () => ({ id: 123, choices: 'nope' }),
    });
    await expect(
      createChatCompletion({ model: 'x', messages: [] }, 'k')
    ).rejects.toThrow();
  });
});

How to verify success

  1. Run tsc --noEmit. The test file should compile with result.choices[0].message.content typed as string, not any.
  2. Run vitest run. Both tests pass: valid payload parses, malformed payload throws.
  3. Temporarily break the schema (e.g., change z.string() to z.number() on id) and rerun tsc. The test fixture assignment should error, proving the type is derived from the schema.

Advanced pattern: infer request bodies too

Once you trust zod infer typescript sdk types for responses, apply it upstream. Define a chatRequestSchema, infer ChatCompletionRequest from it, and validate the outbound body before sending. This catches temperature: "0.5" (string instead of number) at the SDK boundary instead of a 400 from the server.

const chatRequestSchema = z.object({
  model: z.string(),
  messages: z.array(z.object({ role: z.string(), content: z.string() })),
  temperature: z.number().min(0).max(2).optional(),
});
export type ChatCompletionRequest = z.infer<typeof chatRequestSchema>;

Why this beats codegen

OpenAPI generators produce types, but they lag the live API and often emit any for polymorphic fields. Zod schemas live in your repo, are executable, and double as tests. The zod infer typescript sdk types workflow adds ~30 lines of schema for a complete, type-safe chat client. That is a fraction of the surface area of a hand-rolled SDK, and it fails loudly when the backend drifts.

Ship the schema, infer the types, parse the wire. Everything else is plumbing.

Tagstypescriptzodsdktype-inference

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 typescript typed llm api clients posts →