DEV Community

Getting Typed JSON Out of LLMs: Field Notes on generateObject

The Vercel AI SDK's generateObject is the reliable way to get typed, schema-validated JSON out of a language model: I pass a Zod schema, the SDK constrains the model and validates the result, and I get a typed object instead of hand-parsing a string that is JSON most of the time.

Four things carried the weight for me - generateObject for one-shot extraction, streamObject for progressive UI, the object/array/enum/no-schema output modes, and treating NoObjectGeneratedError as a first-class code path.

Key takeaways

  • generateObject({ model, schema, prompt }) returns a typed object validated against a Zod (or JSON) schema; on a schema mismatch it throws instead of handing you bad data.
  • streamObject streams a partial object through partialObjectStream so a form or table fills in field-by-field before the model finishes.
  • The AI SDK has four output modes: object (default), array (streams elements via elementStream), enum (single-label classification), and no-schema (freeform JSON).
  • Use generateObject to extract data and tool calling to take actions; experimental_output combines a tool-calling loop with a final typed object.
  • When a model returns invalid JSON the SDK throws NoObjectGeneratedError, which carries the raw text and usage; experimental_repairText and tighter schema descriptions recover most of those.

What does generateObject actually do?

generateObject is a Vercel AI SDK function that forces a language model to return JSON matching a schema I define, and validates the response before my code ever sees it. I pass a model, a Zod schema, and a prompt; I get back a typed object whose shape TypeScript already knows.

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

const { object } = await generateObject({
  model: 'anthropic/claude-sonnet-5',
  schema: z.object({
    title: z.string(),
    tags: z.array(z.string()).max(5),
    sentiment: z.enum(['positive', 'neutral', 'negative']),
  }),
  prompt: `Summarise this support ticket: ${ticket}`,
});

object.sentiment; // typed as 'positive' | 'neutral' | 'negative'

The schema does double duty: it steers the model toward the right shape, and it validates the output. If the model returns a field that does not parse, generateObject throws rather than returning half-valid data.

Why not just parse JSON from generateText?

Because JSON.parse on a generateText string is exactly the failure mode generateObject exists to remove. A raw completion is a string that is well-formed JSON most of the time - until the model wraps it in a markdown fence, adds a trailing comment, or drops a required field, and the parse throws in production.

generateObject injects the schema into the request, uses each provider's structured-output or tool machinery to constrain generation, and validates the result with your Zod schema before returning. The win is not fewer characters of code. It is that the boundary between model output and typed value has one owner - the schema - instead of being smeared across a prompt, a regex, and a try/catch.

When should I use streamObject instead of generateObject?

Reach for streamObject when the object is big enough that waiting for the whole thing feels slow. streamObject returns a partialObjectStream that yields the object as it is built, so a UI can render fields the moment they arrive.

import { streamObject } from 'ai';

const { partialObjectStream } = streamObject({
  model: 'anthropic/claude-sonnet-5',
  schema: reportSchema,
  prompt,
});

for await (const partial of partialObjectStream) {
  render(partial); // partial is a deep-partial of Report
}

Each emitted value is a deep-partial of the schema, so every field can be undefined until the model fills it. For one-shot server work - a cron job, a route handler that returns once - generateObject is simpler and stays my default.

What output modes does the AI SDK support?

Mode What you get Use it for
object (default) one validated object extraction, summarization, a single record
array elements via elementStream lists where each row renders as it lands
enum one string from a fixed set classification, routing, yes/no gates
no-schema arbitrary parsed JSON exploratory prompts with an unknown shape

For enum I pass output: 'enum' and an enum: ['spam', 'not_spam'] list; the model can only return one of those exact strings, which is stricter and cheaper than an object with one enum field.

When do I use tool calling instead of generateObject?

Use generateObject to extract a value and tool calling to do something. generateObject has no side effects: it turns unstructured input into one typed object and stops. Tool calling - generateText or streamText with a tools map - lets the model decide to call functions, possibly several times in a loop, before it answers.

import { generateText, Output } from 'ai';

const { experimental_output } = await generateText({
  model: 'anthropic/claude-sonnet-5',
  tools: { searchOrders },
  experimental_output: Output.object({ schema: answerSchema }),
  prompt,
});

experimental_output - still flagged experimental - is the bridge: the model runs its tool-calling loop and then returns a final answer validated against a schema, so I get the actions and a typed result from one call.

How do I handle a model that returns invalid JSON?

When generation fails schema validation, the AI SDK throws NoObjectGeneratedError, and catching it explicitly is the difference between a graceful fallback and a 500. The error carries the raw text the model produced, plus usage and response.

import { generateObject, NoObjectGeneratedError } from 'ai';

try {
  const { object } = await generateObject({ model, schema, prompt });
  return object;
} catch (err) {
  if (NoObjectGeneratedError.isInstance(err)) {
    logger.warn({ text: err.text, usage: err.usage }, 'invalid object from model');
    return fallback;
  }
  throw err;
}

Three things cut my failure rate before the catch block runs.

  1. .describe() on every non-obvious field - the description is sent to the model, so z.string().describe('ISO 8601 date') beats hoping.
  2. .nullable() over .optional() for fields the model might not know, because many models emit null more reliably than they omit a key.
  3. experimental_repairText, a callback that can strip a code fence or trailing comma before the SDK re-parses.

I only retry after those three, because a retry doubles latency and cost.

FAQ

Q: What is the difference between generateObject and generateText?

A: generateText returns a free-form string; generateObject returns a typed object validated against a schema and throws if the output does not match. Use generateText for prose, generateObject whenever you need machine-readable data.

Q: Does generateObject work with any model?

A: It works with any provider the AI SDK supports, but the mechanism varies - some use native structured-output JSON mode, others tool calling under the hood. You pass the same Zod schema regardless; models with native structured output are the most reliable.

Q: Can I stream a structured object to the browser?

A: Yes. streamObject returns a partialObjectStream of deep-partial objects. Render each field as it arrives and treat every field as possibly undefined until the stream completes.

Q: How do I classify text without an object wrapper?

A: Use output: 'enum' with an enum array of allowed labels. The model must return exactly one of the strings, which is stricter and cheaper than an object with a single enum field.

Q: What throws when the model output is malformed?

A: NoObjectGeneratedError. It exposes the raw text, usage, and response so you can log and fall back. Reduce its frequency with field .describe() hints, .nullable() over .optional(), and an experimental_repairText callback.

Originally published on devya.dev. Also on eng-ahmed.com. Built by Devya Solutions.

Comments

No comments yet. Start the discussion.