Schema design as error prevention

Lesson 4 of 5 in Structured Outputs: Getting Data, Not Prose.

Every rung of the retry ladder is a tax you pay for a schema that let the model fail. The cheapest failure is the one the contract made unrepresentable — which makes schema design the highest-leverage work in this whole module.

You have met these moves before, aimed the other way: tool input schemas use them to stop bad arguments arriving. Output schemas use them to stop bad data leaving. Same moves, same reason — the schema is the only part of your design the model actually reads at decision time.

Weak schema (invites failure)

{
  "type": "object",
  "properties": {
    "priority": { "type": "string" },
    "order_id": { "type": "string" },
    "refund_amount": { "type": "number" },
    "reason": { "type": "string" },
    "metadata": { "type": "object" }
  },
  "required": ["priority", "order_id", "refund_amount", "reason"]
}

Five fields, five open doors. priority accepts "urgent-ish". order_id accepts anything, so an invented one looks like a real one. refund_amount accepts -4 and 98000000, and “number” hides the dollars-or-cents question your ledger cares about. metadata is a free-form object — an unbounded surface for the model to be creative in. And every field is required, so a ticket with no order number forces the model to produce one.

Hardened schema

{
  "type": "object",
  "additionalProperties": false,
  "properties": {
    "priority": { "type": "string", "enum": ["p1", "p2", "p3"] },
    "order_ref": {
      "type": ["string", "null"],
      "pattern": "^ORD-[0-9]{4}-[0-9]{6}
quot;, "description": "Order ID exactly as it appears in the ticket, e.g. 'ORD-2026-114873'. Null if the ticket does not state one — do not infer it." }, "refund_cents": { "type": "integer", "minimum": 0, "maximum": 50000 }, "reason_code": { "type": "string", "enum": ["damaged", "late", "wrong_item", "duplicate_charge", "other"] }, "evidence_quote": { "type": "string", "maxLength": 300 } }, "required": ["priority", "order_ref", "reason_code", "refund_cents", "evidence_quote"] }

Closed sets became enums. The id got a pattern and an explicit null escape hatch. The amount became an integer in minor units with a policy ceiling. Free-text reason became a reason_code your code can branch on, with other as the honest overflow. metadata is gone. evidence_quote is new: a bounded span the model must copy from the source, which makes the classification auditable.

The same contract in code

Most teams define the schema once in their language of choice and export JSON Schema from it — one source of truth for the decoder, the validator, and the compiler.

const Triage = z.object({
  priority: z.enum(['p1', 'p2', 'p3']),
  orderRef: z.string().regex(/^ORD-\d{4}-\d{6}$/).nullable(),
  refundCents: z.number().int().min(0).max(50_000),
  reasonCode: z.enum(['damaged', 'late', 'wrong_item', 'duplicate_charge', 'other']),
  evidenceQuote: z.string().max(300),
})

Two cautions. First, the export is lossy in both directions: a constrained decoder supports a subset of JSON Schema, so a construct your type library emits happily may be rejected, silently ignored, or hard-fail at request time — test it. Second, do not reuse your database entity as the model’s output type: every extra column is another field the model must invent, and it welds your storage schema to your prompt.

Typed agent frameworks lean on exactly this pattern — Pydantic AI, for instance, is built around type-safe structured outputs with a typed Python model as the contract.

Six moves, and what each one actually buys
MoveWhat it preventsWhat it costs

Enum over free string

Invented categories, casing drift, synonyms your switch does not handle.

You must enumerate the set — and add an explicit other so novel cases surface instead of being crammed into the nearest wrong bucket.

Pattern + an example value in the description

Free-form identifiers and format guesswork. The pattern constrains; the example teaches the format in a handful of tokens.

A tight pattern makes a fabricated id look real, so it must be paired with an existence check. Exotic regex is also the first thing a constrained decoder refuses.

Bounded numbers, integers, minor units

Negative refunds, absurd totals, float drift on money, and the dollars-versus-cents ambiguity.

Bounds encode policy in two places, so they drift from the real policy unless generated from it.

An explicit “unknown” — nullable, or a status enum

Forced confabulation. A required field with no escape hatch cannot be left out, so the model fills it.

Downstream code must handle the null branch honestly instead of assuming a value is always present.

Flat and shallow, additionalProperties: false

Deep nesting the model mis-assembles, oneOf branches it picks wrongly, and stray extra keys that pass unnoticed.

Some genuinely nested data gets flattened awkwardly; strict mode rejects benign additions, so schema changes need versioning.

A required evidence field

Unauditable claims. Making the model quote its source turns “trust me” into something a reviewer or a judge model can check.

Output tokens, and a quote that can itself be fabricated — verify it appears verbatim in the source.

Where does this rule belong?

Interactive decision tree — outcomes:

  • Put it in the schema

    Enum, pattern, bound, nullability. Cheapest possible enforcement: it shapes generation and fails validation, so you get prevention and detection from one line.

  • Make it a lookup, not a field

    Do not ask the model to produce a value the system already knows — give it a tool to resolve or search, and let the schema carry the resolved id. Generated identifiers are hallucinations waiting for a pattern to hide behind.

  • Put it in the semantic validator

    Cross-field invariants, business policy, recomputed totals, anything context-dependent. Runs right after parsing, in code, on every response — and its error message is what rung two shows the model.

  • Put it in the authorization layer

    Never in the schema and never in the prompt. Whether this actor may refund this order is a runtime decision made by the harness against the acting identity — a field the model fills in is a field an injected instruction can set.

Interactive checkpoint quiz (2 questions) — open this page in a browser to take it.