Command Palette

Search for a command to run...

Hectal
PHASE 7Intermediate ~24 min· topic 2 of 7

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, a code and a message. error.issues lists all of them.
Path
Where in the value the issue is, as a list: ["items", 0, "qty"] means the qty of 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 be null. 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.

order-card.tswhole filets
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.

See the contract the model receivesdiagram
Rendering diagram…

Try it yourself

  1. 1

    Write a friendlier message

    In the first example, change days: z.number().int().min(0) to days: z.number().int().min(0, "Days cannot be negative"). Run it.

    The negative line 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. 2

    Make a field nullable

    In the first example, change days to z.number().int().min(0).nullable() and add check("unknown days", { orderId: "ORD-1042", status: "processing", days: null }).

    It now passes. missing still fails, because nullable means the key must be present. That is the difference from .optional().

  3. 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

One schema, eleven values

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

Press Run to see the page.

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 3
Absent, null, default, strict, and string limits

Line 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

Press Run to see the page.

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"
The contract the model receives

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

Press Run to see the page.

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(),.

terminal
$ Console, the bad enum line
── what you'll see ──
bad enum: ok { orderId: 'ORD-1042', status: 'lost', days: 2 }

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() or z.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 a z.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. 1

    The problem this solves: Topic 7.1 ended with hand-written typeof checks. 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. 2

    zod is a small library for writing schemas in JavaScript and TypeScript. You import z and 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 of x, 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. 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 a ZodError. In UI code where a bad reply is a normal event, use safeParse and branch on success. The first live example runs eleven values through one schema.

  4. 4

    Reading the errors. error.issues is a list. Each issue has a path (where the problem is, such as ["days"] or ["items", 0, "qty"]), a code and a message such as Invalid 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. 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 be null. .default(x): if it is missing, fill in x. And .nullish() allows both. They are *not* interchangeable: a model that writes null for an .optional() field fails. Pick one meaning for "the model does not know" and use it everywhere. A good habit is null for "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. 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 "😀".length does: "😀😀😀" 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. 7

    Unknown keys are stripped. By default zod drops keys that are not in the schema (the extra key line of the example). That is usually what you want for model output: whatever extra the model invented never travels on. z.strictObject rejects unknown keys and z.looseObject keeps them.

  8. 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, and z.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

01

What are the three jobs one zod schema does in an AI feature?

02

What is the difference between safeParse and parse, and which belongs in UI code?

03

A model writes null for a field. When does that pass and when does it fail?

04

Why is an enum better than a string for an order status coming from a model?

05

What does zod do with keys that are not in the schema, and when would you change that?

Practice

01

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.

02

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 safeParse and read issues (path and message).

  • I can choose between optional, nullable and default and 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.