The pain generateObject solves
Getting a chat model to *say* something is easy. Getting it to return data your TypeScript app can trust — every field present, every type correct — is the hard part. The old approach was "ask for JSON in the prompt, then JSON.parse and pray," patched with regex when the model wrapped its answer in ````json fences or added a chatty preamble.
The Vercel AI SDK replaces that with two functions — generateObject and streamObject — that constrain the model to a Zod schema and hand you back a fully typed object. Under the hood the SDK uses each provider's native structured-output mechanism (JSON mode for OpenAI, tool-use extraction for Anthropic), and if the model returns something malformed, it retries automatically before throwing.
A first example
You describe the shape once, in Zod, and the return type is inferred from it:
import { generateObject } from "ai";
import { z } from "zod";
const { object } = await generateObject({
model: "openai/gpt-5.2",
schema: z.object({
recipe: z.object({
name: z.string(),
ingredients: z.array(z.object({ name: z.string(), amount: z.string() })),
steps: z.array(z.string()),
}),
}),
prompt: "Generate a lasagna recipe.",
});
// object.recipe.ingredients[0].amount — fully typed, no casting, no parse step.The Zod schema does triple duty: it becomes the JSON Schema sent to the model, it validates the response on arrival, and it gives you the static TypeScript type for free. One source of truth.
streamObject for progressive UIs
generateObject waits for the whole object before returning — fine for a backend job, sluggish for a UI where the user stares at a spinner for three seconds. streamObject streams partial objects as the model produces them, so fields populate progressively:
import { streamObject } from "ai";
const { partialObjectStream } = streamObject({
model: "openai/gpt-5.2",
schema: reportSchema,
prompt: "Summarize this earnings call into a structured report.",
});
for await (const partial of partialObjectStream) {
// partial is a deep-partial of your schema — render what's arrived so far.
render(partial);
}Rule of thumb: use generateObject for extraction/classification that feeds other code, and streamObject for anything a person watches load.
Designing schemas the model reads well
The schema is also a prompt — the model sees your field names and .describe() annotations. A few habits that raise reliability:
- Name fields in plain language.
shippingAddressbeatsaddr2. The model infers meaning from the key. - Add `.describe()` for anything ambiguous.
z.enum(["low","medium","high"]).describe("urgency of the ticket")removes guesswork. - Keep nesting shallow. Deeply nested schemas are both harder for the model and more token-expensive; flatten where you can.
- Prefer `z.enum` over free strings for categorical fields so you never post-process typos.
Validate the shape before you ship it
Because the whole contract lives in one Zod schema, it pays to get that schema right before wiring it into an app. A fast loop with JSONKit:
- Sketch a realistic response by hand and format it to confirm the shape reads the way you expect.
- Generate a JSON Schema from that sample to sanity-check required vs. optional fields, then mirror it in Zod — or go the other way and convert JSON to a Zod schema to scaffold the Zod code.
- When a response looks off in production, diff it against a known-good example to see exactly which field drifted.
For the bigger picture on why Zod pairs so well with LLMs, see Zod for AI: Type-Safe LLM Output and LLM JSON Mode & Structured Outputs.