Topic 7.2
Schemas with Zod
In one line
A schema is a short, exact description of the shape you expect. With zod you write it once and use it three times: it tells the model what to produce, it checks what came back, and it gives your code a type. A failed check gives a list of issues, each with a path and a message.
Think of it like this
The check-in desk at an airport. The officer has a fixed list: a name, a photo, an expiry date in the future. They do not care how good your story is. Either every box on the list is satisfied, or they tell you exactly which one is not. A schema is that list, and zod is the officer.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Schema
- An exact description of the shape a value must have: fields, types, allowed values.
- zod
- A JavaScript/TypeScript library for writing schemas and checking values against them.
- safeParse
- Checks a value against a schema and returns a result object. Never throws.
- parse
- Checks a value and returns it, or throws a
ZodError. - Issue
- One problem found by a check: a
path, acodeand amessage.error.issueslists all of them. - Path
- Where in the value the issue is, as a list:
["items", 0, "qty"]means theqtyof the first item. - Enum
- A fixed list of allowed values, such as three order statuses.
- optional / nullable
optional: the key may be missing.nullable: the value may benull. Different things.- JSON Schema
- A standard format for describing JSON data. It is what providers receive when you ask for structured output.
- z.infer
- A TypeScript helper that gives the type of the data a schema accepts.
Step by step
01Describe the shape once
Start with the card from Topic 7.1: an order id, one of three statuses, and a whole number of days. The id must look like ORD- followed by digits.
import { z } from "zod";
export const OrderCard = z.object({
orderId: z.string().regex(/^ORD-\d+$/),
status: z.enum(["shipped", "processing", "cancelled"]),
days: z.number().int().min(0),
});
export type OrderCard = z.infer<typeof OrderCard>; // { orderId: string; status: "shipped" | …; days: number }02Check values and read the issues
The first live example sends good and bad values through safeParse. The console prints each issue as path -> message. Read them: a string where a number was expected, a missing key, a status outside the enum, an id that does not match the pattern, a float where an integer was needed, and a negative number.
Two results are worth noticing. A value with an extra key passes (and the key is gone from data). And null or [] fail with an empty path, because the problem is the whole value.
03Absent, null, default
The second example shows the four "may be absent" tools side by side, then strict and loose objects, then string limits with emoji and Hindi text. Notice that optional rejects null and nullable rejects a missing key.
04See the contract the model receives
The third example prints the JSON Schema that zod produces, and what the AI SDK's Output.object hands to the provider as the response format. This is the "form" from Topic 7.1: field names, the allowed words, and the .describe() text.
Two details to notice. required lists only the keys that are not optional, and additionalProperties: false says no other keys are allowed.
Try it yourself
- 1
Write a friendlier message
In the first example, change
days: z.number().int().min(0)todays: z.number().int().min(0, "Days cannot be negative"). Run it.The
negativeline now prints your sentence instead of zod's. You can give any limit its own message; this is how form errors get written (Topic 7.5). - 2
Make a field nullable
In the first example, change
daystoz.number().int().min(0).nullable()and addcheck("unknown days", { orderId: "ORD-1042", status: "processing", days: null }).It now passes.
missingstill fails, because nullable means the key must be present. That is the difference from.optional(). - 3
Count characters your own way
In the second example, add
"é"and"e\u0301"(an e followed by a combining accent) to the list of names. Both look like one letter.They have different lengths (1 and 2), because the second is two code points. Zod counts code points, so with
.max(1)the first passes and the second fails, and a visible-letter limit can still surprise you with accented text from some keyboards.
Code & diagrams
Every issue has a path and a message, and all problems are listed together. These messages are for developers and logs, not for customers.
Preview
Console
Nothing logged yet.Expected console output
good: ok { orderId: 'ORD-1042', status: 'shipped', days: 2 }
wrong type: days -> Invalid input: expected number, received string
missing: days -> Invalid input: expected number, received undefined
bad enum: status -> Invalid option: expected one of "shipped"|"processing"|"cancelled"
bad pattern: orderId -> Invalid string: must match pattern /^ORD-\d+$/
float: days -> Invalid input: expected int, received number
negative: days -> Too small: expected number to be >=0
extra key: ok { orderId: 'ORD-1042', status: 'shipped', days: 2 }
not an object: -> Invalid input: expected object, received null
array: -> Invalid input: expected object, received array
all wrong: orderId -> Invalid input: expected string, received undefined | status -> Invalid option: expected one of "shipped"|"processing"|"cancelled" | days -> Invalid input: expected number, received undefined
false ZodError 3
✖ Invalid input: expected string, received undefined
→ at orderId
✖ Invalid option: expected one of "shipped"|"processing"|"cancelled"
→ at status
✖ Invalid input: expected number, received undefined
→ at days
parse throws: ZodError 3Line 2 and line 3 are the classic mix-up: a field marked optional rejects null, and a nullable field cannot be left out. The family emoji is one visible character but five code points, so it fails max(1); JS length says 8.
Preview
Console
Nothing logged yet.Expected console output
only required + nullable: { required: 'a', nullable: null, withDefault: 'standard' }
optional given null: optional -> Invalid input: expected string, received null
nullable missing: nullable -> Invalid input: expected string, received undefined
default used: { required: 'a', nullable: 'x', withDefault: 'standard' }
default overridden: { required: 'a', nullable: 'x', withDefault: 'express' }
default: { a: 'x' }
strict: Unrecognized key: "extra"
loose: { a: 'x', extra: 1 }
"" length 0 Too small: expected string to have >=1 characters
"chai" length 4 ok
"chai!!" length 6 Too big: expected string to have <=5 characters
"😀😀" length 4 ok
"😀😀😀" length 6 ok
"चाय" length 3 ok
one family emoji, max(1): false | JS length: 8
trim: "Too small: expected string to have >=1 characters"note is optional, so it is missing from required. days is required but may be null. The .describe() texts travel with the schema as guidance for the model.
Preview
Console
Nothing logged yet.Expected console output
{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"orderId":{"type":"string","description":"The order number, like ORD-1042"},"status":{"type":"string","enum":["shipped","processing","cancelled"]},"days":{"anyOf":[{"type":"integer","minimum":0,"maximum":9007199254740991},{"type":"null"}],"description":"Days until delivery, or null if unknown"},"note":{"type":"string"}},"required":["orderId","status","days"],"additionalProperties":false,"description":"A card showing one order's status"}
required: [ 'orderId', 'status', 'days' ] | extra keys allowed: false
json type,schema
["orderId","status","days"]Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Loosen the enum to a plain string
In the first example, change status: z.enum(["shipped", "processing", "cancelled"]), to status: z.string(),.
Myth vs fact
Myth
A schema makes the model produce correct data.
Fact
A schema decides what is *accepted*. It can be sent to the model as guidance, but only your validator enforces it, and it cannot know whether the status is actually true.
Myth
.optional() and .nullable() mean the same.
Fact
Optional allows a missing key, nullable allows the value null. A model that writes null for an optional field fails validation.
Myth
.describe() text is a rule.
Fact
It is a hint added to the schema sent to the model. Nothing checks it. Only the validators (.enum, .min, .regex…) reject values.
Myth
More validation rules always make output safer.
Fact
Every rule is another way a reply can be rejected. Validate what your screen needs, and nothing more.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Share one schema file between server and client (
import { OrderCard } from "@/lib/schemas"). The server validates what the model produced; the client validates what the server sent. When the shape changes you edit one place, and TypeScript shows every screen that needs updating. - ▸
Schemas and saved data age differently. If you store validated objects (a chat history that contains cards), a later schema change can make old records fail validation. Add new fields as
.optional()or.default(), and treat removing or renaming a field as a migration. - ▸
Do not use
z.any()orz.unknown()for the model's fields to "be flexible". That moves the same silent bugs from parse time to render time. If a field's shape really varies, model it as az.discriminatedUnion(Topic 7.3 and 7.6). - ▸
A numeric limit in the schema is not a security measure. A hostile or confused reply can still be large. Cap the size of the whole reply (output token limit) as well as individual fields (Phase 13).
Remember this
- 1
The problem this solves: Topic 7.1 ended with hand-written
typeofchecks. They get long, they miss cases, and they cannot tell the model what you want. You need one short description of the shape that does three jobs: tell the model what to write, check the reply, and tell your code (and TypeScript) what fields it can rely on. - 2
zod is a small library for writing schemas in JavaScript and TypeScript. You import
zand build the shape:z.object({ orderId: z.string(), days: z.number() }). Each piece is a *validator*:z.string()accepts only strings,z.number()only numbers,z.array(x)a list ofx,z.enum(["a", "b"])only those exact words. The AI SDK uses zod (you can also pass other schema libraries), and this course uses zod 4. - 3
Two ways to check.
schema.safeParse(value)never throws. It returns{ success: true, data }or{ success: false, error }.schema.parse(value)returns the data or throws aZodError. In UI code where a bad reply is a normal event, usesafeParseand branch onsuccess. The first live example runs eleven values through one schema. - 4
Reading the errors.
error.issuesis a list. Each issue has apath(where the problem is, such as["days"]or["items", 0, "qty"]), acodeand amessagesuch asInvalid input: expected number, received string. All issues are reported together, so a reply that is wrong in three places lists three issues. These messages are for you and your logs. Do not show them to customers; show a plain sentence (Topic 7.3), or, for a form, your own messages (Topic 7.5). - 5
Four ways to say "this may be absent".
.optional(): the key may be missing..nullable(): the key must be there, and its value may benull..default(x): if it is missing, fill inx. And.nullish()allows both. They are *not* interchangeable: a model that writesnullfor an.optional()field fails. Pick one meaning for "the model does not know" and use it everywhere. A good habit isnullfor "not found in the text", so the model has a legal way to say "unknown" instead of inventing a value. Some providers' strict schema modes also want every key present, which is another reason to prefer.nullable(); check your provider's documentation. - 6
Enums and limits. An enum is the most useful validator for AI output.
status: z.enum(["shipped", "processing", "cancelled"])means a made-up status is rejected before any screen sees it. Limits such as.int(),.min(0),.max(10),.regex(/^ORD-\d+$/)narrow things further. Do not over-constrain: every limit is one more way the reply can fail, and then you pay for a retry. String length counts characters the way a person does, not the way"😀".lengthdoes:"😀😀😀"passes.max(5)and a family emoji counts as five characters, because zod counts code points. Write messages for limits in plain words:.min(1, "Please enter a name"). - 7
Unknown keys are stripped. By default zod drops keys that are not in the schema (the
extra keyline of the example). That is usually what you want for model output: whatever extra the model invented never travels on.z.strictObjectrejects unknown keys andz.looseObjectkeeps them. - 8
The schema is the contract.
Output.object({ schema })(Topic 7.3) turns your zod schema into JSON Schema, the standard description format, and sends it to the provider..describe("…")text travels with it as guidance for the model. The same schema validates the reply, andz.infer<typeof Schema>gives you the TypeScript type. Put the schema in one file that both server and client import, so they can never disagree. Treat.describe()as a hint, not a rule: only the validator enforces anything.
Explain it without notes
What are the three jobs one zod schema does in an AI feature?
What is the difference between safeParse and parse, and which belongs in UI code?
A model writes null for a field. When does that pass and when does it fail?
Why is an enum better than a string for an order status coming from a model?
What does zod do with keys that are not in the schema, and when would you change that?
Practice
Write a zod schema for a product card: name (non-empty string), price (number, at least 0), inStock (boolean) and note (text or unknown). Decide whether note should be optional or nullable and say why. Test it with one good value and three bad ones.
Write a function explain(error) that turns a zod error into one sentence per issue for a developer log, in the form items[0].qty: expected number, received string.
Trade-offs
- ↔
Strict schemas catch more bad replies and give clearer errors, but each rule can also reject a reply that a person would find fine, which costs a retry. Keep rules to what the screen needs.
- ↔
A shared schema file keeps server and client in step, but it makes every schema change a coordinated release. Additive changes (new optional fields) are safe; renames are not.
Done when you can
I can write a zod object schema with strings, numbers, an enum and an array.
I can use
safeParseand readissues(path and message).I can choose between
optional,nullableanddefaultand explain the difference.I can say what happens to unknown keys and how to change it.
I can show the JSON Schema a zod schema produces and explain what the model does with it.