AI/TLDR

What Is Zod? TypeScript Schemas for LLM Output

You will understand how Zod defines TypeScript schemas that validate and type LLM responses at runtime, and why it became the standard schema layer for JS/TS AI apps.

INTERMEDIATE11 MIN READUPDATED 2026-06-14

In plain English

When you ask a language model for JSON, you get back a string. The model promises it's valid JSON shaped the way you wanted, but a string is just text — your program has no idea whether the price field is really a number, whether email is actually present, or whether the model wrapped everything in a chatty sentence. In TypeScript this gap is sharp: the compiler trusts your types at build time, but it has no idea what the model will actually send at runtime.

Zod — illustration
Zod — datatalks.club

Zod is a TypeScript-first schema library that closes that gap. You describe the shape you expect once, and Zod gives you two things at the same time: a static TypeScript type for the compiler, and a runtime parser that checks real data and either hands you a clean, fully-typed object or throws a clear error. One definition, two guarantees — compile-time and runtime.

Think of Zod as a bouncer at the door of your code. The model's reply walks up claiming to be a User with a name, an age, and an email. The bouncer doesn't take its word for it — it checks every field against the guest list. If everything matches, it waves a typed object through and the rest of your app can trust it completely. If the age is missing or the email is malformed, the bouncer turns it away at the door instead of letting a half-broken object cause a crash three functions later.

Why it matters

An LLM response is untrusted input, no different in spirit from a form a user fills out or a payload from a third-party API. It can be malformed, missing fields, the wrong type, or subtly off in ways that only blow up later. TypeScript's types vanish when you compile to JavaScript, so they can't protect you here — you need a check that runs on the actual data at runtime. That's the core job Zod does.

The problems it solves

  • No more as lies. Writing JSON.parse(reply) as User tells the compiler to assume the shape is correct without checking anything. If the model omits a field, you get undefined at runtime and a crash far from the real cause. Zod actually verifies, so a parsed object is genuinely what its type says.
  • One source of truth. You define the schema once. The TypeScript type and the runtime validator can never drift apart, because Zod derives the type from the schema. Change the schema and the type changes with it.
  • Clear, structured errors. When validation fails, Zod tells you exactly which field was wrong and why. That's the difference between debugging Cannot read property 'x' of undefined and reading 'expected number, received string at items[0].price'.
  • It feeds the model your contract. Most LLM SDKs can turn a Zod schema into the JSON Schema they send to the provider, so the same definition both tells the model what to produce and checks what it produced.

Who cares? Anyone building a TypeScript app that consumes structured LLM output: a function-calling agent, a 'extract these fields from this document' pipeline, a classifier that must return one of a fixed set of labels, or any tool that pulls a typed object out of free-form text. The moment your code does something with result.someField, you want a guarantee that someField is really there and really the right type. Zod is that guarantee.

How it works

Zod has a tiny mental model. You build a schema object by composing small pieces — z.string(), z.number(), z.object({...}), and so on. That schema is a real value at runtime, but it also carries enough type information for the compiler to infer the matching TypeScript type. Then you call .parse() on incoming data: valid data comes back typed, invalid data throws.

define once, get a type and a validatortypescript
import { z } from "zod";

// 1) Declare the shape ONCE.
const User = z.object({
  name: z.string(),
  age: z.number().int().positive(),
  email: z.string().email(),
  role: z.enum(["admin", "member", "guest"]),
  tags: z.array(z.string()).optional(),
});

// 2) Derive the static TypeScript type for free.
type User = z.infer<typeof User>;
// => { name: string; age: number; email: string;
//      role: "admin" | "member" | "guest"; tags?: string[] }

// 3) Validate unknown data at runtime.
const data: unknown = JSON.parse(modelReply);
const user = User.parse(data); // throws if the shape is wrong
// `user` is now fully typed AND guaranteed to match at runtime.

The key insight is that z.infer reads the type out of the schema. You never write the User interface by hand — it's generated from the same object that does the checking, so the two can't disagree.

The validation pipeline for an LLM call

In an AI app, that one schema travels through the whole round-trip. You convert it to JSON Schema so the model knows the target shape, the model generates text aiming at that shape, then you parse the text back through the same schema before your code touches it.

parse vs safeParse

Zod gives you two ways to run a schema. .parse() returns the typed value or throws on failure — good when a bad response is a real error you want to bubble up. .safeParse() never throws; it returns a result object with success: true and the data, or success: false and the error. For LLM output, safeParse is often the friendlier choice because a malformed reply is expected sometimes, and you'd rather branch on it than catch an exception.

safeParse — branch instead of throwtypescript
const result = User.safeParse(JSON.parse(modelReply));

if (!result.success) {
  // Re-ask the model, log it, or fall back — your choice.
  console.error(result.error.issues);
} else {
  const user = result.data; // typed and validated
  saveUser(user);
}

From Zod schema to the JSON Schema the model sees

Models don't understand Zod — they understand JSON Schema, the standard, language-neutral way to describe a JSON shape. The bridge is a converter that turns your Zod object into the equivalent JSON Schema. Many LLM SDKs do this for you when you hand them a Zod schema; you can also do it explicitly.

the same shape, expressed as JSON Schematypescript
// The Zod object above converts to JSON Schema roughly like this:
{
  "type": "object",
  "properties": {
    "name":  { "type": "string" },
    "age":   { "type": "integer", "exclusiveMinimum": 0 },
    "email": { "type": "string", "format": "email" },
    "role":  { "enum": ["admin", "member", "guest"] },
    "tags":  { "type": "array", "items": { "type": "string" } }
  },
  "required": ["name", "age", "email", "role"]
}

This is why one Zod definition can do double duty. The provider uses the JSON Schema to constrain generation — with native structured outputs the model is forced to emit text that matches the schema — and then you re-validate with the original Zod schema to be certain. The model-side constraint and the app-side check stay in lockstep because they come from the same place.

Common pitfalls

Zod is easy to start with and easy to misuse in ways that quietly weaken the guarantee you wanted.

  • Casting instead of parsing. JSON.parse(reply) as User is not validation — it's a promise to the compiler that you never checked. The whole point of Zod is to replace that cast with a real .parse(). If you still see as on model output, the bouncer is asleep.
  • Forgetting the JSON might not parse at all. Zod validates shape, not whether the string is valid JSON. The model can return prose around the JSON or truncated text. Call JSON.parse first (in a try/catch or via safeParse on the result), then validate the object.
  • Over-rich schemas the model can't satisfy. Deeply nested objects, long enums, and many .refine() rules make it more likely the model produces something that fails. Start with the flattest schema that captures what you need; tighten later.
  • Loose types that pass but mislead. z.any() or making everything .optional() makes validation always succeed while telling you nothing. A schema that can't fail isn't protecting you. Make required fields required.
  • Coercion surprises. Helpers like z.coerce.number() will turn the string "5" into 5. That's handy for messy model output, but be deliberate — silent coercion can hide a model that's returning the wrong type.

Zod vs JSON Schema vs plain casting

It helps to see where Zod sits among the other ways people 'type' LLM output. They aren't all rivals — Zod and JSON Schema actually work together — but builders often confuse them.

ApproachRuntime check?Static TS type?Best for
x as Type castNoYes (unverified)Never, for untrusted input
Hand-written if checksYesManual, driftsOne-off tiny payloads
Raw JSON SchemaYes (with a validator)No, not by itselfLanguage-neutral contracts
ZodYesYes, inferredTypeScript apps parsing LLM output

The pattern most TypeScript AI apps land on: author the contract in Zod, convert it to JSON Schema to constrain the model, and parse the reply back through Zod to verify. You get one definition, a real runtime check, and a static type — without writing the JSON Schema or the validator by hand. (In Python land, the equivalent role is played by Pydantic; Zod is the JS/TS counterpart.)

Going deeper

Once the basic parse-on-the-way-in habit clicks, a few more capabilities make Zod genuinely powerful for LLM work.

Transforms and refinements. .transform() lets a schema reshape data as it validates — trim a string, parse a date, normalize a field — so what comes out is not just checked but cleaned. .refine() and .superRefine() add custom rules that simple types can't express, like cross-field constraints. Remember these run in your runtime, not in the model, so they're a post-generation safety net rather than something the model is told about.

Composition and reuse. Schemas are values, so you compose them: .extend() to add fields, .merge() to combine objects, .partial() to make everything optional, z.discriminatedUnion() to model 'one of several shapes' (perfect for a model that returns different payloads per action type). One small schema can be reused across many prompts and endpoints.

Streaming and partial validation. When you stream a structured response token by token, the JSON is incomplete until the very end, so a strict full parse fails mid-stream. The common approaches are to validate only once the stream completes, or to use a partial/relaxed schema for in-progress UI updates and the full schema for the final value. Pair Zod with solid streaming error handling so a dropped or malformed stream degrades gracefully.

The validate-and-retry loop. Because safeParse gives you a precise list of issues, you can build a self-correcting loop: if parsing fails, send the model its own bad output plus the validation errors and ask it to fix them. This turns Zod from a gatekeeper into a feedback signal, and it's the backbone of how robust extraction and agent pipelines stay reliable. The durable lesson: treat every model reply as untrusted until a schema has vouched for it, and let the schema be the single contract shared by the model, your types, and your runtime.

FAQ

What is Zod used for in AI apps?

Zod defines the shape of the structured output you expect from a language model, then validates the model's reply at runtime so your code receives a typed, trustworthy object. The same schema can be converted to JSON Schema and sent to the model, so it both describes the target shape and verifies the result.

How does Zod work with LLM structured output?

You write one Zod schema, convert it to JSON Schema for the provider (many SDKs do this automatically), let the model generate JSON aiming at that shape, then call .parse() or .safeParse() on the reply. Valid data comes back fully typed; invalid data throws or returns a structured error you can act on.

What is the difference between parse and safeParse in Zod?

.parse() returns the typed value or throws if validation fails. .safeParse() never throws — it returns an object with success: true and data, or success: false and error. For LLM output, safeParse is often preferred because a malformed reply is expected occasionally and you'd rather branch on it than catch an exception.

Does Zod replace JSON Schema for LLMs?

No — they work together. Models understand JSON Schema, not Zod, so your Zod schema is converted to JSON Schema to constrain or guide the model. After the model responds, you validate the reply with the original Zod schema. Zod is the authoring and runtime layer; JSON Schema is the wire format the model sees.

Can Zod stop an LLM from hallucinating?

No. Zod guarantees the structure of the output — correct fields, correct types — but it cannot tell whether the values are factually true. A valid-looking email or number can still be invented. Validation prevents crashes from malformed data; accuracy still needs grounding, evaluation, and verification.

Is Zod the same as Pydantic?

They play the same role in different languages. Pydantic is the standard schema-and-validation library for Python LLM apps; Zod is its counterpart in JavaScript and TypeScript. Both let you declare a schema once and get runtime validation, and both are widely used to define and parse structured model output.

Further reading