Structured Outputs and JSON Schema
Force the model into a schema instead of parsing its vibes. When constrained decoding helps, and when the model still fills fields with junk.
- json
- schema
- zod
JSON.parse on a chat reply will fail on a Tuesday. A markdown fence, a preface (“Sure, here is the JSON”), a trailing comma, an em-dash in a string you did not expect. Structured outputs are the adult version: you hand the API a JSON Schema (or a Zod schema that compiles to one), and constrained decoding only allows tokens that keep the string valid against that schema.
Valid JSON can still be a wrong invoice. The schema says totalCents is an integer. It does not say the integer matches the line items. Parse, then apply business rules. If either step fails, do not show a success toast.
Key takeaways
- Constrained decoding makes the shape reliable. It does not make the values true.
- Prefer JSON Schema / Zod over “respond as JSON” in the prompt.
- JSON mode (valid JSON, any keys) is weaker than a schema with
additionalProperties: false.- Validate money, ids, and enums in your code after the object parses.
Structured outputs beat JSON.parse
Unconstrained chat is a string source. Frontends then grow a graveyard of repair functions: strip fences, find the first {, hope.
function parseJsonFromVibes(text: string): unknown {
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/);
const raw = fenced?.[1] ?? text;
return JSON.parse(raw.trim()); // throws on Tuesday
}
That helper is a smell. Use it only as a fallback for a model that cannot constrain, and still validate with Zod. For anything you ship, turn on structured outputs on the provider.
OpenAI’s structured outputs (JSON Schema in the response format, or function calling with a schema) are built for this. Anthropic documents structured outputs on supported models too. Vendor field names move. The idea does not: schema in, object out.
Schema in, object out
Define the object in Zod. That is the same habit as runtime validation with schemas: TypeScript types erase. The schema runs.
import { z } from 'zod';
const LineItem = z.object({
sku: z.string().min(1),
qty: z.number().int().positive(),
unitCents: z.number().int().nonnegative(),
});
const InvoiceDraft = z.object({
vendorName: z.string().min(1),
invoiceDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
currency: z.enum(['USD', 'EUR', 'GBP']),
items: z.array(LineItem).min(1),
totalCents: z.number().int().nonnegative(),
notes: z.string().nullable(),
});
type InvoiceDraft = z.infer<typeof InvoiceDraft>;
function assertBusinessRules(draft: InvoiceDraft) {
const sum = draft.items.reduce((acc, it) => acc + it.qty * it.unitCents, 0);
if (sum !== draft.totalCents) {
throw new Error(`total mismatch: items=${sum} total=${draft.totalCents}`);
}
}
async function extractInvoice(text: string): Promise<InvoiceDraft> {
const res = await fetch('/api/extract-invoice', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ text }),
});
if (!res.ok) throw new Error(`extract ${res.status}`);
const body: unknown = await res.json();
const draft = InvoiceDraft.parse(body);
assertBusinessRules(draft);
return draft;
}
On the server, map InvoiceDraft to the provider’s JSON Schema. Strict mode typically wants additionalProperties: false and every property listed in required. Optional fields become unions with null, not omitted keys. That constraint is annoying and it is the point: the model cannot invent taxCode.
If you use the Vercel AI SDK, generateObject is the wrapper. The raw idea is the same. See AI SDK structured output when you are on that stack.
Constrained decoding vs JSON mode vs tools
| Mechanism | What it guarantees | What it does not |
|---|---|---|
| Prompt only (“return JSON”) | Nothing | Syntax, keys, types |
JSON mode / json_object |
A parseable object | Key names, enums, extra fields |
| Structured outputs (schema) | Object matches the schema | That totalCents is correct |
| Tool / function schema | The call matches the schema | That your handler should run it |
Use structured outputs when the assistant is the object (extract, classify, form-fill). Use a tool schema when the model is proposing a side effect your server will run. Function calling is structured output aimed at your runtime, not at the user’s screen.
Constrained decoding works by masking invalid next tokens as the JSON is built. That is why it beats regex. It also means a too-tight schema can make the model ramble in a string field it is allowed to fill, or refuse. Log refusals as a first-class status. OpenAI documents explicit refusal shapes on some APIs. Handle them. Do not parse a refusal string.
Valid JSON, wrong invoice
The schema said invoiceDate is YYYY-MM-DD. The model wrote 2026-02-31. Regex passed if you were sloppy. Calendar check fails. Put the calendar check in assertBusinessRules, not in the prompt.
Other junk that survives a schema:
- Hallucinated SKUs that match
z.string().min(1) - Enums picked because they are first in the list
notesstuffed with chain-of-thought you did not want persisted- Quantities that do not match the source PDF because vision misread a column
Grounding is separate: if the source text is missing a total, the model will still emit an integer unless you allow null and instruct it to refuse. Your prompt engineering (the spec) and the schema have to agree. See prompt engineering fundamentals.
Do not “repair” a failed business rule by asking the model to try again in an unbounded loop. Cap retries. Surface the mismatch to a human when money is involved.
Should you stream a structured object?
You can stream tokens of a JSON value. You cannot JSON.parse until the object closes. Partial JSON is a syntax error, not a draft object.
Options:
- Wait for the end, then parse. Simplest. Fine for small extracts.
- Use a streaming parser (or the SDK’s partial object helper) that yields a best-effort tree as keys close. UI can fill fields as they complete. Still run Zod at the end.
- Do not bind a Submit button to a partial. A missing
totalCentsis not0.
If the stream aborts, throw away the partial or mark it incomplete. Writing a half object to the database is how you invent invoices.
Schema size is prompt. A 400-line JSON Schema is tokens you pay for, and it can hurt cache locality if it changes. Keep the object the UI needs, not your entire domain model.
Out loud, in an interview
“Structured outputs constrain decoding to a JSON Schema so I get a parseable object instead of fencing and regex. I still run Zod and business rules after that, because a valid total can be the wrong total. JSON mode is weaker: any keys. For side effects I use tool schemas and authorize in the handler. I would not JSON.parse a partial stream, and I would not retry repairs without a cap.”
Further reading (primary sources)
Related on this site
- Prompt engineering fundamentals
- Function calling and tools
- Structured output with the AI SDK
- Runtime validation with schemas
Related guides
- Structured Output with the AI SDKAI SDK generateObject turns a Zod schema into a typed object. Map it to provider structured outputs, and fail the request when parse fails.
- Output Parsing and RepairLLM output parsing when structured outputs are unavailable: strip fences, repair JSON, re-ask with validator errors, then give up.
- Tool Schema DesignA tool schema is an API the model will misuse. Names, enums, tight types, and descriptions that prevent "search" from becoming "drop table".
- Agent Failure ModesAgents loop, lie about tool results, skip confirmation, and get stuck apologizing. Catalog the failures and the circuit breakers.
- Agent HandoffsA handoff passes the conversation to another agent with a slimmer prompt and different tools. What to copy, what to drop, and how to avoid ping-pong.