Zod Schemas as Output Contracts
12 min read · updated August 4, 2026
A model returns a string. Your component needs an object. Everything between those two sentences is where AI features break in production, and one Zod schema can own all of it: the constraint sent to the model, the validation of what comes back, the error text that drives a repair, and the TypeScript type the component consumes.
One schema, four jobs
// schema.ts
import { z } from "zod";
export const Ticket = z.object({
title: z.string().min(3).max(120),
severity: z.enum(["low", "medium", "high", "critical"]),
component: z.enum(["auth", "billing", "search", "api", "ui", "other"]),
steps_to_reproduce: z.array(z.string()).min(1).max(8),
affects_users: z.boolean(),
// Nullable rather than optional: a model that is unsure should say null
// explicitly. An absent key and an unknown value look identical otherwise,
// and you cannot tell "did not answer" from "answered nothing".
estimated_hours: z.number().min(0).max(200).nullable(),
});
export type Ticket = z.infer<typeof Ticket>;That declaration now does four things. It is the runtime validator. It is the compile-time type, via z.infer, so the type and the check cannot drift apart — which is the whole argument for Zod over a hand-written interface plus a hand-written guard. It produces the JSON Schema you send as the model’s output constraint. And when validation fails it produces a structured error precise enough to hand back as a repair instruction.
z.toJSONSchema(schema). Zod 3 does not, and the usual answer there is the separate zod-to-json-schema package. Check which major version is in your package.json: several surfaces moved between the two, including where string format validators live. This is the one version-sensitive line on the page.import { z } from "zod";
import { Ticket } from "./schema";
// Zod 4:
const jsonSchema = z.toJSONSchema(Ticket);
const body = {
model: "openai/gpt-4o-mini",
messages: [
{ role: "system", content: "Extract a bug report. Use null where unknown." },
{ role: "user", content: report },
],
response_format: {
type: "json_schema",
json_schema: { name: "ticket", schema: jsonSchema, strict: true },
},
};The response_format field is provider-specific and its shape differs across providers and versions — the second thing on this page to check against current documentation rather than copy. What does not differ is the principle: where a provider supports constrained decoding against a schema, use it, because it makes malformed JSON impossible rather than unlikely. The rest of this page is for the models where it is merely unlikely.
Parsing at the boundary
Two failures live here and they need different handling: the string is not JSON, or it is JSON that does not match. Conflating them means the repair prompt tells the model the wrong thing.
// parse.ts
import type { ZodType } from "zod";
export type ParseResult<T> =
| { ok: true; value: T }
| { ok: false; kind: "not-json" | "schema"; detail: string };
/** Models wrap JSON in prose or a fence more often than anyone admits. */
function extractJson(raw: string): string {
const trimmed = raw.trim();
const fence = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/);
if (fence) return fence[1].trim();
// Otherwise take the outermost braces or brackets.
const first = trimmed.search(/[{[]/);
if (first === -1) return trimmed;
const open = trimmed[first];
const close = open === "{" ? "}" : "]";
const last = trimmed.lastIndexOf(close);
return last > first ? trimmed.slice(first, last + 1) : trimmed;
}
export function parseAgainst<T>(schema: ZodType<T>, raw: string): ParseResult<T> {
let json: unknown;
try {
json = JSON.parse(extractJson(raw));
} catch (err) {
return { ok: false, kind: "not-json", detail: (err as Error).message };
}
const result = schema.safeParse(json);
if (result.success) return { ok: true, value: result.data };
// One line per problem, in a form the model can act on directly.
const detail = result.error.issues
.map((i) => i.path.join(".") + ": " + i.message)
.join("\n");
return { ok: false, kind: "schema", detail };
}safeParse rather than parse, deliberately: a thrown ZodError in a route handler becomes a 500 and a stack trace, when what you actually have is a recoverable situation with a known next step. And flattening error.issues into path: message lines is not cosmetic — that text is what the repair turn sends back, so it is worth being readable.
Repair: feeding the error back
A model that produced nearly-correct JSON will usually produce correct JSON when told precisely what was wrong. The repair turn is cheap: it re-sends only the broken output and the error, not the original document, so its input is a few hundred tokens rather than a few thousand.
// repair.ts
import type { ZodType } from "zod";
import { parseAgainst, type ParseResult } from "./parse";
export async function repairOnce<T>(
schema: ZodType<T>,
broken: string,
detail: string,
call: (messages: { role: string; content: string }[]) => Promise<string>,
): Promise<ParseResult<T>> {
const raw = await call([
{
role: "system",
content:
"You fix malformed JSON. Return only the corrected JSON object. " +
"No prose, no code fence, no explanation.",
},
{ role: "user", content: "This output was rejected:\n\n" + broken },
{ role: "user", content: "The validator reported:\n\n" + detail },
]);
return parseAgainst(schema, raw);
}One repair attempt, not a loop. Two failures against the same explicit error message almost never become a success on the third: the model is not confused about the format, it disagrees with the schema or the document does not contain the information. Looping there turns a fast failure into a slow expensive one, which is the general shape of what retries cost.
The escalation ladder, with a cost ceiling
// extract.ts
import type { ZodType } from "zod";
import { parseAgainst } from "./parse";
import { repairOnce } from "./repair";
type Call = (
messages: { role: string; content: string }[],
model: string,
) => Promise<string>;
export type Extracted<T> =
| { value: T; attempts: number }
| { value: null; attempts: number; reason: string };
export async function extract<T>(
schema: ZodType<T>,
messages: { role: string; content: string }[],
call: Call,
opts = { cheap: "openai/gpt-4o-mini", strong: "openai/gpt-4o" },
): Promise<Extracted<T>> {
// 1. Cheap model, schema-constrained where the provider supports it.
const raw = await call(messages, opts.cheap);
const first = parseAgainst(schema, raw);
if (first.ok) return { value: first.value, attempts: 1 };
// 2. One repair turn on the cheap model. Small input, small output.
const repaired = await repairOnce(schema, raw, first.detail, (m) =>
call(m, opts.cheap),
);
if (repaired.ok) return { value: repaired.value, attempts: 2 };
// 3. One attempt on a stronger model. This is the last spend.
const strong = parseAgainst(schema, await call(messages, opts.strong));
if (strong.ok) return { value: strong.value, attempts: 3 };
// 4. Stop. Three attempts is the ceiling; the fourth is a support ticket.
return { value: null, attempts: 3, reason: strong.detail };
}The ceiling is the point of the function. Without it, a document the model genuinely cannot extract from — a scan pasted as gibberish, a field that does not exist in the source — becomes an unbounded retry loop against a paid API, and the first symptom is the bill. Three attempts with a hard stop bounds the worst case at roughly three times the best case, which is a number you can budget for.
Return a null result rather than throwing. The caller has a real decision to make: queue for human review, show a partial result, or ask the user to rephrase. An exception forces all three into one catch.
Schemas a model can actually satisfy
Half of all schema failures are the schema’s fault. The constraints models reliably meet and the ones they reliably do not split cleanly.
| Rule | Description |
|---|---|
| Flat beats nested | Three levels of nesting produce more structural errors than three top-level objects extracted separately. Depth costs accuracy for no gain in expressiveness. |
| Enums beat free strings | z.enum([...]) gives the model a closed set. A free z.string() for a category yields fourteen spellings of the same thing across a thousand documents. |
| Nullable beats optional | An absent key is ambiguous between not-applicable and not-found. An explicit null is a decision the model made, and you can act on it. |
| Describe every field | .describe("ISO 8601 date, or null") becomes a description in the generated JSON Schema, which the model reads. The cheapest accuracy improvement available. |
| Never ask for computed values | A total, a percentage, a count: models are unreliable at arithmetic and you have a computer. Extract the parts and compute the whole in TypeScript. |
| Bound every array | .max(20) on an array is a cost control as much as a validation rule. An unbounded array is how a 200-token answer becomes a 4,000-token one on an unusual input. |
More on schema design itself in schemas a model can fill in and the edge cases.
Partial objects while streaming
Streaming structured output has an inherent tension: a partial JSON string is not valid JSON, so a schema requiring every field cannot validate anything until the last brace arrives. Two honest options.
- Do not stream the object; stream the status. Show “reading document”, then “extracting fields”, and render the result when it validates. For a form-filling feature this is better than watching braces appear, and it is far less code.
- Validate a relaxed schema per chunk. Derive a partial version — every field optional — and render only fields present and valid, then validate against the strict schema at the end. This needs a tolerant incremental JSON parser, which is a real dependency and a real source of subtle bugs; streaming JSON parsing covers what that costs.
// The relaxed twin, derived from the strict schema so they cannot drift. export const PartialTicket = Ticket.partial(); export type PartialTicket = z.infer<typeof PartialTicket>; // Render what has arrived; the strict parse still gates anything that writes. const view = PartialTicket.safeParse(partialJson); if (view.success) setDraft(view.data);
Whichever you choose, the strict schema stays the gate for anything that persists or spends. A relaxed schema is a rendering convenience, never a validation boundary.