ESC

Type to search the knowledge base.

Structured Output with the AI SDK

AI SDK generateObject turns a Zod schema into a typed object. Map it to provider structured outputs, and fail the request when parse fails.

intermediate6 min read
  • ai-sdk
  • zod
  • structured-output

JSON.parse on a chat reply will fail on a Tuesday: a fence, a trailing comma, an apology before the brace. AI SDK generateObject (and the current generateText({ output }) form) is the difference between a form-filler and a regex graveyard. You hand in a Zod schema. You get a typed object, or the call fails in a way you can handle.

Valid JSON can still be a wrong invoice. The schema is a type. Business rules are a second pass.

Key takeaways

  • Prefer provider structured outputs through the SDK over “please reply in JSON.”
  • Zod (or JSON Schema) is the contract. Parse failure is a failed request, not a string to tinker with forever.
  • Repair is a bounded retry with validator errors, not an infinite cleanup loop.
  • Check domain invariants after parse: amounts, ids, enums you actually support.
  • Stream partial objects only when the UI can render incomplete trees safely.

AI SDK generateObject is schema plus a request

The mental model is one function: schema plus prompt in, object out. That is what AI SDK generateObject names.

import { generateObject } from 'ai';
import { z } from 'zod';

const TicketDraft = z.object({
  title: z.string().min(1).max(120),
  severity: z.enum(['low', 'medium', 'high']),
  stepsToReproduce: z.array(z.string()).min(1),
  component: z.enum(['checkout', 'auth', 'docs', 'other']),
});

export async function draftTicket(fromNotes: string) {
  const { object } = await generateObject({
    model: 'openai/gpt-4.1-mini',
    schema: TicketDraft,
    prompt: `Turn these notes into a ticket.\n${fromNotes}`,
  });
  return object;
}

SDK versions drift. Current docs fold this into generateText with output: Output.object({ schema }) and you read result.output. Older examples import generateObject and read result.object. Same job: constrain decoding, validate, type the result. Use whatever your installed ai package documents. Do not keep a hand-rolled fence stripper.

Zod .describe() strings are prompt. They are cheaper than a paragraph of “the title should be short.”

This sits on top of structured outputs and JSON Schema. That page is the provider idea. This page is the TypeScript API you actually call from a Next.js route.

When parse fails, fail the request

Do not return {} and hope the form is blank. Surface a 422 or a typed error to the UI.

import { generateText, Output, NoObjectGeneratedError } from 'ai';
import { z } from 'zod';

export async function extractInvoice(raw: string) {
  try {
    const result = await generateText({
      model: 'openai/gpt-4.1-mini',
      output: Output.object({
        schema: z.object({
          invoiceId: z.string(),
          lineItems: z.array(
            z.object({
              sku: z.string(),
              qty: z.number().int().positive(),
              unitCents: z.number().int().nonnegative(),
            }),
          ),
          totalCents: z.number().int().nonnegative(),
        }),
      }),
      prompt: `Extract the invoice.\n${raw}`,
    });
    return result.output;
  } catch (err) {
    if (NoObjectGeneratedError.isInstance?.(err)) {
      const fail = new Error('Could not extract an invoice');
      (fail as Error & { status: number }).status = 422;
      throw fail;
    }
    throw err;
  }
}

Map that 422 in the Route Handler. The point is the status code: this is a validation miss, not a 500 from your database.

Repair is a budget

Some SDK versions accept repairText on object generation. The idea is portable even if the option name moves: take the bad text plus the schema error, ask once or twice, then stop.

async function generateWithRepair<T>(
  schema: z.ZodType<T>,
  prompt: string,
  repairOnce: (badText: string, error: string) => Promise<string>,
): Promise<T> {
  const first = await generateObject({
    model: 'openai/gpt-4.1-mini',
    schema,
    prompt,
  }).catch(async (err) => {
    const text = typeof err?.text === 'string' ? err.text : '';
    if (!text) throw err;
    const fixed = await repairOnce(text, String(err.cause ?? err));
    return generateObject({
      model: 'openai/gpt-4.1-mini',
      schema,
      prompt: `Fix this JSON so it matches the schema.\nError: ${String(err.cause ?? err)}\nJSON:\n${fixed}`,
    });
  });
  return first.object;
}

Cap at one repair in product code. A second failure is a model or schema problem. Loops that “try to parse again” with jsonrepair on arbitrary model prose will eventually accept a plausible lie.

Prefer native structured outputs (constrained decoding) so repair stays rare. If the provider cannot constrain, you are in output parsing territory.

Business rules after parse

The object can be well-typed and still wrong.

function assertInvoiceMath(
  invoice: {
    lineItems: { qty: number; unitCents: number }[];
    totalCents: number;
  },
) {
  const sum = invoice.lineItems.reduce(
    (acc, line) => acc + line.qty * line.unitCents,
    0,
  );
  if (sum !== invoice.totalCents) {
    throw new Error('total_mismatch');
  }
}

Other checks that schemas will not save:

  • invoiceId exists in your database
  • component maps to a real owner
  • dates are not “next Tuesday”
  • arrays are not 400 items because you forgot max

Put those in your route, next to auth. The model is not the accounting layer.

Should you stream the object?

Use streamObject / streamText with an object output when the UI can show fields as they close: a title appearing, then a list of steps. Do not bind a submit button to a partial tree. Incomplete objects are not schema-valid. The SDK may expose partialObjectStream that is explicitly unvalidated.

Mode Use when
generateObject / generateText + object output You need the full record before any side effect
Streamed partials Preview only: forms, outlines, live classification chips
Text + your own parse Last resort when the provider has no schema mode

For a chat UI that happens to return JSON, structured output is usually the wrong product. Chat wants tokens. Extraction wants an object. Split the routes.

Prompt still matters

A schema does not replace a spec. Say what each field is for. Give one short example if the shape is ambiguous. Do not few-shot five full invoices into the prompt unless that earns its tokens. Prompt engineering still applies. Temperature should be low for extraction. “Creative” sampling invents SKUs.

Footguns

  • .passthrough() on the schema. Extra keys from the model become extra keys in your DB.
  • z.string() for money. Use integer cents.
  • Streaming partials into JSON.parse. You will parse '{"title":"He' and throw on every frame.
  • Catching parse errors and returning the raw text to the client. Now the UI is a parser again.
  • Huge nested schemas. Constrained decoding can get fussy. Split into two calls if the provider starts refusing the schema.

Interview answer (out loud)

“I use the AI SDK object generators with a Zod schema, generateObject or generateText with Output.object depending on the version. That maps to the provider’s structured output mode. If parse fails I fail the request, maybe repair once with the validator error, then stop. After parse I run business checks: totals, ids, allowed enums. I only stream partial objects for preview, never for writes.”

Further reading (primary sources)

Related guides