n4nAI

Structured outputs with Zod and the OpenAI Node.js SDK

Learn how to enforce typed JSON responses from LLMs using the OpenAI Node.js SDK and Zod for reliable structured outputs in production apps.

n4n Team3 min read660 words

Audio narration

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

The openai node.js sdk zod structured outputs pattern lets you trade fragile string parsing for typed, validated JSON straight from the model. This tutorial builds a runnable pipeline that defines a Zod schema, sends it to a chat completion, and gets back an object you can trust at runtime. We’ll use TypeScript and the official zodResponseFormat helper so the SDK handles JSON Schema conversion and parsing.

Prerequisites

  • Node.js 20 or later
  • An API key from OpenAI (or any OpenAI-compatible gateway)
  • Familiarity with ES modules and TypeScript

Install the dependencies:

npm init -y
npm install openai zod dotenv
npm install -D tsx typescript

Create a .env file:

OPENAI_API_KEY=sk-...

We’ll write the code in src/classify.ts and run it with npx tsx src/classify.ts.

Define the Zod schema

Start by modeling the data you want back. Zod gives you a single source of truth for types and runtime validation.

import { z } from "zod";

export const TicketSchema = z.object({
  priority: z.enum(["low", "medium", "high", "critical"]),
  category: z.enum(["billing", "technical", "account", "other"]),
  summary: z.string().min(10).max(200),
  tags: z.array(z.string()).max(5),
});

export type Ticket = z.infer<typeof TicketSchema>;

The zodResponseFormat helper converts this to the strict JSON Schema the API expects. Strict mode forces the model to produce exactly these fields with no extras, which removes the need for post-hoc cleanup.

Initialize the client

The OpenAI Node SDK reads OPENAI_API_KEY from the environment automatically. If you want to target a different backend, set baseURL.

import OpenAI from "openai";
import { zodResponseFormat } from "openai/helpers/zod";
import "dotenv/config";

const client = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
  // Optional: route through an OpenAI-compatible gateway.
  // baseURL: "https://api.n4n.ai/v1",
});

Keep the client singleton. It manages connection pooling and retries.

Make a structured completion call

Now we call chat.completions.create with response_format set by the helper. The second argument to zodResponseFormat is a name for the schema—required by the API.

import { TicketSchema } from "./schema";

async function classify(text: string): Promise<Ticket> {
  const completion = await client.chat.completions.create({
    model: "gpt-4o-2024-08-06",
    messages: [
      { role: "system", content: "You are a support ticket classifier." },
      { role: "user", content: text },
    ],
    response_format: zodResponseFormat(TicketSchema, "ticket"),
  });

  const parsed = completion.choices[0].message.parsed;
  if (!parsed) throw new Error("No parsed payload");
  return TicketSchema.parse(parsed);
}

classify("My invoice doubled and I can't access my account.").then(console.log);

Run it:

npx tsx src/classify.ts

Expected output (field order may vary):

{
  "priority": "high",
  "category": "billing",
  "summary": "User reports duplicate charge and inability to log in.",
  "tags": ["invoice", "billing", "login"]
}

The parsed property is already a JavaScript object. The extra TicketSchema.parse call is defensive—structured outputs are strict, but validating at the boundary keeps your app safe if you later swap models or gateways.

Guide the model with field descriptions

Zod lets you attach describe() to fields; the helper forwards these as JSON Schema descriptions, which the model sees as hints.

const TicketSchema = z.object({
  priority: z.enum(["low", "medium", "high", "critical"]).describe("Urgency based on user impact"),
  category: z.enum(["billing", "technical", "account", "other"]).describe("Primary issue domain"),
  summary: z.string().min(10).max(200).describe("One sentence rewording of the user's problem"),
  tags: z.array(z.string()).max(5).describe("Short keywords for routing"),
});

This costs nothing at runtime and often improves classification accuracy because the model gets context for ambiguous enums.

Handle errors explicitly

API calls fail. Wrap the call so you can distinguish Zod errors from API errors.

async function safeClassify(text: string): Promise<Ticket | null> {
  try {
    return await classify(text);
  } catch (err) {
    if (err instanceof z.ZodError) {
      console.error("Model returned invalid shape:", err.issues);
    } else {
      console.error("API error:", (err as Error).message);
    }
    return null;
  }
}

In practice, pair this with a retry on rate limits (status 429). The SDK retries idempotent requests by default, but you control fallback logic.

Extract multiple records

Often you need a list. Wrap the object in an array field—never pass a top-level array to zodResponseFormat, because the API requires an object wrapper.

const BatchSchema = z.object({
  tickets: z.array(TicketSchema),
});

async function classifyBatch(texts: string[]) {
  const completion = await client.chat.completions.create({
    model: "gpt-4o-2024-08-06",
    messages: [
      { role: "system", content: "Classify each ticket separately." },
      { role: "user", content: texts.join("\n---\n") },
    ],
    response_format: zodResponseFormat(BatchSchema, "batch"),
  });
  return BatchSchema.parse(completion.choices[0].message.parsed);
}

Expected shape:

{
  "tickets": [
    { "priority": "low", "category": "other", "summary": "...", "tags": [] },
    { "priority": "critical", "category": "technical", "summary": "...", "tags": ["outage"] }
  ]
}

Nested structures

You can nest objects. Strict mode requires all nested objects to have additionalProperties: false, which the helper sets automatically.

const CustomerSchema = z.object({
  id: z.string(),
  plan: z.enum(["free", "pro", "enterprise"]),
});

const RichTicketSchema = z.object({
  ticket: TicketSchema,
  customer: CustomerSchema,
  escalated: z.boolean(),
});

Call with zodResponseFormat(RichTicketSchema, "rich_ticket"). The parsed result preserves the nested shape.

Swap models without rewriting code

The openai node.js sdk zod structured outputs rely only on the response_format contract, so any compliant server works. If you uncomment the baseURL line pointing at n4n.ai, the same script can address 240+ models through one endpoint and the gateway will automatically fall back when a provider is rate-limited or degraded. Your TicketSchema stays identical; only the model string changes (e.g., "anthropic/claude-3.5-sonnet").

That portability is the real win: you write the schema once, then route to the cheapest or fastest model that satisfies latency targets.

Production checklist

  • Cache the schema name: zodResponseFormat builds JSON Schema on every call. Hoist it to a constant if you call in a hot loop.
  • Set max_tokens explicitly. Structured fields can balloon with arrays; cap them.
  • Read usage: completion.usage gives prompt/completion tokens. On a gateway like n4n.ai, this maps to per-token metering with no extra code.
  • Forward cache hints: if your gateway supports provider cache-control, set extra_headers on the request; n4n.ai forwards those hints to the upstream provider.
  • Validate at the edge: never trust parsed alone in a long-lived service. Keep the Zod.parse call.

Full runnable example

src/schema.ts:

import { z } from "zod";

export const TicketSchema = z.object({
  priority: z.enum(["low", "medium", "high", "critical"]).describe("Urgency based on user impact"),
  category: z.enum(["billing", "technical", "account", "other"]).describe("Primary issue domain"),
  summary: z.string().min(10).max(200).describe("One sentence rewording of the user's problem"),
  tags: z.array(z.string()).max(5).describe("Short keywords for routing"),
});

export type Ticket = z.infer<typeof TicketSchema>;

src/classify.ts:

import OpenAI from "openai";
import { zodResponseFormat } from "openai/helpers/zod";
import { z } from "zod";
import "dotenv/config";
import { TicketSchema, type Ticket } from "./schema";

const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

async function classify(text: string): Promise<Ticket> {
  const completion = await client.chat.completions.create({
    model: "gpt-4o-2024-08-06",
    messages: [
      { role: "system", content: "You are a support ticket classifier." },
      { role: "user", content: text },
    ],
    response_format: zodResponseFormat(TicketSchema, "ticket"),
  });
  const parsed = completion.choices[0].message.parsed;
  if (!parsed) throw new Error("Empty parse");
  return TicketSchema.parse(parsed);
}

const sample = "My invoice doubled and I can't access my account.";
classify(sample)
  .then((t) => console.log(JSON.stringify(t, null, 2)))
  .catch((e) => console.error(e));

Run npx tsx src/classify.ts. You’ll get a typed object, not a string. That’s the whole point of the openai node.js sdk zod structured outputs approach: move the guessing out of your business logic and into a schema the model must satisfy.

If you need to evolve the shape, change the Zod schema, bump the schema name string, and ship. The compiler and the API will catch drift before your users do.

Tagsnodejsopenai-sdkzodstructured-outputs

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 node.js openai-compatible sdk integration posts →