Skip to content
47% off: the complete course for $79 (reg. $149)Order now →
AI Builder
Module 4Full course10 min read

Designing Output Schemas with Zod and Pydantic

Schema design choices that change answer quality: field order, enums, descriptions, optional vs. nullable, and reasoning fields that come before the answer.

Once a model is constrained to your schema, the schema becomes the most influential prompt in your feature. Every field name, every enum value, every description, and even the order of the fields shapes what the model writes. Two schemas that describe "the same" data can produce noticeably different answer quality: one invites the model to guess, the other gives it an honest way to say "not stated."

This lesson covers the design choices that matter most, using Zod in TypeScript and Pydantic in Python: ordering fields so evidence comes before verdicts, closing open-ended strings into enums, writing descriptions that act as per-field instructions, choosing between optional and nullable, and keeping the schema the model fills separate from the richer types your app uses internally.

A schema has three readers

Before the details, a mindset shift. Your output schema is read by three different consumers, and a good design serves all of them:

  1. The model reads field names, descriptions, and enum values as instructions. It fills the schema in order, one token at a time.
  2. Your code reads the parsed result. It needs types that are easy to branch on and impossible to misinterpret.
  3. Your storage and UI keep the result around. They care about stable names, units, and whether a value can be missing.

Here's a schema written only for the third reader, the way you'd sketch a database row:

✕ Before
import { z } from "zod";
 
export const RefundRequestBad = z.object({
  order: z.string(),
  amount: z.number(),
  reason: z.string(),
  urgent: z.boolean(),
});

Every field is required and non-nullable, so when a customer doesn't mention an order number, the model must invent one. amount has no unit or currency. reason is free text, so you'll get "double charge," "charged twice," and "billing error" for the same thing. urgent has no definition.

✓ After
src/features/refunds/schema.ts
import { z } from "zod";
 
export const RefundRequest = z.object({
  orderNumber: z
    .string()
    .nullable()
    .describe('The Deskmate order number, formatted like "DM-10442". null if the customer did not give one.'),
  amountCents: z
    .number()
    .int()
    .nullable()
    .describe("Amount the customer wants back, in cents. null if no amount is stated. Never compute or estimate it."),
  currency: z.enum(["usd", "eur", "gbp"]).nullable().describe("null if the currency isn't clear from the ticket."),
  reason: z.enum(["double_charge", "not_as_described", "cancelled_too_late", "service_outage", "other"]),
  reasonDetail: z
    .string()
    .nullable()
    .describe('One sentence in the customer\'s words. Required when reason is "other"; otherwise null.'),
  customerDeadline: z.iso
    .date()
    .nullable()
    .describe("A date the customer says they need this resolved by, as YYYY-MM-DD. null if none."),
});

Missing facts have an explicit null. Units are in the name. Reasons are a closed set with an escape hatch. The vague urgent flag is replaced by a concrete fact (a stated deadline) that your code can turn into urgency with a rule you control.

The rest of this lesson unpacks the decisions behind the second version.