{"slug": "zod-schemas-as-output-contracts", "title": "Zod Schemas as Output Contracts", "summary": "A developer demonstrates how Zod schemas can serve as output contracts for AI models, unifying runtime validation, TypeScript types, JSON Schema generation, and repair instructions. The approach uses Zod 4's built-in z.toJSONSchema() and distinguishes between malformed JSON and schema mismatches for effective error handling.", "body_md": "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.\n\n``` js\n// schema.ts\nimport { z } from \"zod\";\n\nexport const Ticket = z.object({\n  title: z.string().min(3).max(120),\n  severity: z.enum([\"low\", \"medium\", \"high\", \"critical\"]),\n  component: z.enum([\"auth\", \"billing\", \"search\", \"api\", \"ui\", \"other\"]),\n  steps_to_reproduce: z.array(z.string()).min(1).max(8),\n  affects_users: z.boolean(),\n  // Nullable rather than optional: a model that is unsure should say null\n  // explicitly. An absent key and an unknown value look identical otherwise,\n  // and you cannot tell \"did not answer\" from \"answered nothing\".\n  estimated_hours: z.number().min(0).max(200).nullable(),\n});\n\nexport type Ticket = z.infer<typeof Ticket>;\n```\n\nThat declaration now does four things. It is the runtime validator. It is the compile-time type, via `z.infer`\n\n, 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.\n\nZod 4 ships a built-in `z.toJSONSchema(schema)`\n\n. Zod 3 does not, and the usual answer there is the separate `zod-to-json-schema`\n\npackage. Check which major version is in your `package.json`\n\n: several surfaces moved between the two, including where string format validators live. This is the one version-sensitive line on the page.\n\n``` js\nimport { z } from \"zod\";\nimport { Ticket } from \"./schema\";\n\n// Zod 4:\nconst jsonSchema = z.toJSONSchema(Ticket);\n\nconst body = {\n  model: \"openai/gpt-4o-mini\",\n  messages: [\n    { role: \"system\", content: \"Extract a bug report. Use null where unknown.\" },\n    { role: \"user\", content: report },\n  ],\n  response_format: {\n    type: \"json_schema\",\n    json_schema: { name: \"ticket\", schema: jsonSchema, strict: true },\n  },\n};\n```\n\nThe `response_format`\n\nfield 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](https://multigrid.ai/learn/structured-output), 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.\n\nTwo 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.\n\n``` python\n// parse.ts\nimport type { ZodType } from \"zod\";\n\nexport type ParseResult<T> =\n  | { ok: true; value: T }\n  | { ok: false; kind: \"not-json\" | \"schema\"; detail: string };\n\n/** Models wrap JSON in prose or a fence more often than anyone admits. */\nfunction extractJson(raw: string): string {\n  const trimmed = raw.trim();\n\n  const fence = trimmed.match(/```\n{% endraw %}\n(?:json)?\\s*([\\s\\S]*?)\n{% raw %}\n```/);\n  if (fence) return fence[1].trim();\n\n  // Otherwise take the outermost braces or brackets.\n  const first = trimmed.search(/[{[]/);\n  if (first === -1) return trimmed;\n  const open = trimmed[first];\n  const close = open === \"{\" ? \"}\" : \"]\";\n  const last = trimmed.lastIndexOf(close);\n  return last > first ? trimmed.slice(first, last + 1) : trimmed;\n}\n\nexport function parseAgainst<T>(schema: ZodType<T>, raw: string): ParseResult<T> {\n  let json: unknown;\n  try {\n    json = JSON.parse(extractJson(raw));\n  } catch (err) {\n    return { ok: false, kind: \"not-json\", detail: (err as Error).message };\n  }\n\n  const result = schema.safeParse(json);\n  if (result.success) return { ok: true, value: result.data };\n\n  // One line per problem, in a form the model can act on directly.\n  const detail = result.error.issues\n    .map((i) => i.path.join(\".\") + \": \" + i.message)\n    .join(\"\\n\");\n\n  return { ok: false, kind: \"schema\", detail };\n}\n```\n\n`safeParse`\n\nrather than `parse`\n\n, deliberately: a thrown `ZodError`\n\nin 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`\n\ninto `path: message`\n\nlines is not cosmetic — that text is what the repair turn sends back, so it is worth being readable.\n\nA 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.\n\n``` python\n// repair.ts\nimport type { ZodType } from \"zod\";\nimport { parseAgainst, type ParseResult } from \"./parse\";\n\nexport async function repairOnce<T>(\n  schema: ZodType<T>,\n  broken: string,\n  detail: string,\n  call: (messages: { role: string; content: string }[]) => Promise<string>,\n): Promise<ParseResult<T>> {\n  const raw = await call([\n    {\n      role: \"system\",\n      content:\n        \"You fix malformed JSON. Return only the corrected JSON object. \" +\n        \"No prose, no code fence, no explanation.\",\n    },\n    { role: \"user\", content: \"This output was rejected:\\n\\n\" + broken },\n    { role: \"user\", content: \"The validator reported:\\n\\n\" + detail },\n  ]);\n\n  return parseAgainst(schema, raw);\n}\n```\n\nOne 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](https://multigrid.ai/learn/retry-cost).\n\n``` python\n// extract.ts\nimport type { ZodType } from \"zod\";\nimport { parseAgainst } from \"./parse\";\nimport { repairOnce } from \"./repair\";\n\ntype Call = (\n  messages: { role: string; content: string }[],\n  model: string,\n) => Promise<string>;\n\nexport type Extracted<T> =\n  | { value: T; attempts: number }\n  | { value: null; attempts: number; reason: string };\n\nexport async function extract<T>(\n  schema: ZodType<T>,\n  messages: { role: string; content: string }[],\n  call: Call,\n  opts = { cheap: \"openai/gpt-4o-mini\", strong: \"openai/gpt-4o\" },\n): Promise<Extracted<T>> {\n  // 1. Cheap model, schema-constrained where the provider supports it.\n  const raw = await call(messages, opts.cheap);\n  const first = parseAgainst(schema, raw);\n  if (first.ok) return { value: first.value, attempts: 1 };\n\n  // 2. One repair turn on the cheap model. Small input, small output.\n  const repaired = await repairOnce(schema, raw, first.detail, (m) =>\n    call(m, opts.cheap),\n  );\n  if (repaired.ok) return { value: repaired.value, attempts: 2 };\n\n  // 3. One attempt on a stronger model. This is the last spend.\n  const strong = parseAgainst(schema, await call(messages, opts.strong));\n  if (strong.ok) return { value: strong.value, attempts: 3 };\n\n  // 4. Stop. Three attempts is the ceiling; the fourth is a support ticket.\n  return { value: null, attempts: 3, reason: strong.detail };\n}\n```\n\nThe 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.\n\nReturn 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`\n\n.\n\nHalf of all schema failures are the schema’s fault. The constraints models reliably meet and the ones they reliably do not split cleanly.\n\n| Rule | Description |\n|---|---|\n| 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. |\n| 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. |\n| 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. |\n| 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. |\n| 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. |\n| 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. |\n\nMore on schema design itself in [schemas a model can fill in](https://multigrid.ai/learn/llm-friendly-schemas) and [the edge cases](https://multigrid.ai/learn/schema-edge-cases).\n\nStreaming 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.\n\n```\n// The relaxed twin, derived from the strict schema so they cannot drift.\nexport const PartialTicket = Ticket.partial();\nexport type PartialTicket = z.infer<typeof PartialTicket>;\n\n// Render what has arrived; the strict parse still gates anything that writes.\nconst view = PartialTicket.safeParse(partialJson);\nif (view.success) setDraft(view.data);\n```\n\nWhichever 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.", "url": "https://wpnews.pro/news/zod-schemas-as-output-contracts", "canonical_source": "https://dev.to/multigrid/zod-schemas-as-output-contracts-3ej5", "published_at": "2026-08-12 18:26:11+00:00", "updated_at": "2026-08-12 18:47:56.673368+00:00", "lang": "en", "topics": ["developer-tools", "artificial-intelligence", "machine-learning"], "entities": ["Zod", "OpenAI", "GPT-4o-mini", "zod-to-json-schema"], "alternates": {"html": "https://wpnews.pro/news/zod-schemas-as-output-contracts", "markdown": "https://wpnews.pro/news/zod-schemas-as-output-contracts.md", "text": "https://wpnews.pro/news/zod-schemas-as-output-contracts.txt", "jsonld": "https://wpnews.pro/news/zod-schemas-as-output-contracts.jsonld"}}