{"slug": "type-safe-llm-outputs-with-zod-stop-guessing-what-the-model-returns", "title": "Type-safe LLM outputs with Zod: stop guessing what the model returns.", "summary": "A developer describes how to enforce type-safe LLM outputs using Zod schemas, preventing runtime errors from unexpected model responses. The approach covers defining schemas, integrating with Vercel AI SDK and Anthropic SDK, and implementing retry loops for parse failures.", "body_md": "I shipped a classifier to production in January. The prompt asked for JSON with a single `category`\n\nfield. For three weeks it worked fine. Then the model started returning `{\"category\":\"bug\",\"explanation\":\"this looks like a crash\"}`\n\nand the consumer threw a runtime error because it only expected one key. No schema change, no deploy. The model just decided to be helpful.\n\nZod plus a bit of discipline around the parse step closes that gap. This tutorial walks through defining schemas for LLM output shapes, using them with the Vercel AI SDK and the raw Anthropic SDK, and building a retry loop that handles the cases where the model still gets it wrong.\n\n| Step | What | Why |\n|---|---|---|\n| Define Zod schema | Describe the shape you want | Single source of truth for your types |\n| Use generateText with Output.object | Vercel AI SDK path | Schema-enforced, provider-agnostic |\n| Use tool use with tool_choice | Anthropic SDK path | Forces structured output without extra wrappers |\n| Parse and retry on failure | ZodError catches drift | Recovers without crashing callers |\n\nMost LLM tutorials show `JSON.parse(response)`\n\nand call it a day. The problem is that the model never agreed to your schema. Ask it to return `{\"category\": \"bug\"}`\n\nand it might return:\n\n`{\"category\": \"bug\"}`\n\n(correct)`{\"Category\": \"Bug\"}`\n\n(wrong casing)`{\"category\": \"bug\", \"confidence\": 0.9}`\n\n(extra field)`{\"error\": \"I cannot classify this\"}`\n\n(helpful, but not your schema)Without a parse step that actually validates the shape, every one of those paths silently corrupts downstream data.\n\nThe fix is three lines of Zod plus one `.safeParse()`\n\ncall. Every technique in this article builds on that pattern, whether you use the Vercel AI SDK, the raw Anthropic SDK, or both.\n\n``` js\nimport { z } from \"zod\";\n\nconst ClassifyResult = z.object({\n  category: z.enum([\"bug\", \"feature\", \"question\"]),\n});\n\ntype ClassifyResult = z.infer<typeof ClassifyResult>;\n\n// At runtime:\nconst parsed = ClassifyResult.safeParse(JSON.parse(rawOutput));\nif (!parsed.success) {\n  // parsed.error is a ZodError with field-level detail\n  console.error(\"Shape violation:\", parsed.error.issues);\n}\n```\n\nInstall Zod 4 (currently the stable major):\n\n```\nnpm install zod@^4.0.0\n```\n\nThe core APIs (`z.object`\n\n, `z.string`\n\n, `z.enum`\n\n, `z.discriminatedUnion`\n\n, `z.infer`\n\n) all carry over from Zod 3. If you're already on Zod 3, the migration is largely additive for these use cases.\n\nLLM outputs tend to fall into three shapes: flat classifiers, richer extractors, and discriminated results where the model picks a branch. Zod handles all three.\n\n``` js\nimport { z } from \"zod\";\n\nexport const SentimentSchema = z.object({\n  sentiment: z.enum([\"positive\", \"negative\", \"neutral\"]),\n  score: z.number().min(-1).max(1),\n});\n\nexport type Sentiment = z.infer<typeof SentimentSchema>;\njs\nexport const InvoiceSchema = z.object({\n  vendor: z.string(),\n  amount_usd: z.number().positive(),\n  due_date: z.string().regex(/^\\d{4}-\\d{2}-\\d{2}$/),\n  line_items: z.array(\n    z.object({\n      description: z.string(),\n      quantity: z.number().int().positive(),\n      unit_price: z.number().positive(),\n    })\n  ),\n});\n\nexport type Invoice = z.infer<typeof InvoiceSchema>;\n```\n\nThis is the shape to reach for when you want the model to pick one of three or more distinct output paths rather than a flat enum.\n\n``` js\nexport const RoutingResult = z.discriminatedUnion(\"intent\", [\n  z.object({\n    intent: z.literal(\"search\"),\n    query: z.string(),\n    filters: z.array(z.string()).optional(),\n  }),\n  z.object({\n    intent: z.literal(\"create\"),\n    resource_type: z.string(),\n    fields: z.record(z.string(), z.unknown()),\n  }),\n  z.object({\n    intent: z.literal(\"clarify\"),\n    question: z.string(),\n  }),\n]);\n\nexport type RoutingResult = z.infer<typeof RoutingResult>;\n```\n\nThe discriminated union is strict: if `intent`\n\nis `\"search\"`\n\n, Zod knows to expect `query`\n\n, and a parse attempt with `intent: \"create\"`\n\nplus a `query`\n\nfield fails cleanly.\n\nThe Vercel AI SDK added schema-native structured output in recent versions. You pass a Zod schema through `Output.object`\n\nand the SDK handles the prompt scaffolding and parse step for you.\n\n``` js\nimport { generateText, Output } from \"ai\";\nimport { anthropic } from \"@ai-sdk/anthropic\";\nimport { z } from \"zod\";\n\nconst SentimentSchema = z.object({\n  sentiment: z.enum([\"positive\", \"negative\", \"neutral\"]),\n  score: z.number().min(-1).max(1),\n});\n\nasync function analyzeSentiment(text: string) {\n  const { output } = await generateText({\n    model: anthropic(\"claude-sonnet-4-5-20251022\"),\n    output: Output.object({ schema: SentimentSchema }),\n    prompt: `Analyze the sentiment of this text: \"${text}\"`,\n  });\n\n  // output is typed as { sentiment: \"positive\" | \"negative\" | \"neutral\"; score: number }\n  return output;\n}\n\n// Usage\nconst result = await analyzeSentiment(\"The deploy went sideways at 3am.\");\nconsole.log(result.sentiment); // TypeScript knows this is the enum\nconsole.log(result.score);     // TypeScript knows this is a number\n```\n\nThe return type flows from the schema without any casting. If the model returns something that does not match, the SDK throws before the result reaches your code.\n\nFor streaming partial objects as they arrive:\n\n``` js\nimport { streamText, Output } from \"ai\";\n\nconst { partialOutputStream } = streamText({\n  model: anthropic(\"claude-sonnet-4-5-20251022\"),\n  output: Output.object({ schema: InvoiceSchema }),\n  prompt: \"Extract the invoice details from this text: ...\",\n});\n\nfor await (const partial of partialOutputStream) {\n  // partial is a Partial<Invoice> as fields arrive\n  console.log(partial);\n}\n```\n\nAdd an `onError`\n\ncallback for stream errors since they arrive in-band rather than as thrown exceptions.\n\nIf you use the Anthropic SDK directly and want schema-enforced output without the Vercel SDK wrapper, the reliable path is tool use with `tool_choice`\n\nforced to your schema tool.\n\nThe idea: define a \"tool\" whose `input_schema`\n\ndescribes the JSON shape you want. Force the model to call that tool with `tool_choice: { type: \"tool\", name: \"...\" }`\n\n. The model then returns a `tool_use`\n\nblock with structured input instead of free text. You parse that input with Zod.\n\n``` python\nimport Anthropic from \"@anthropic-ai/sdk\";\nimport { z } from \"zod\";\n\nconst client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });\n\n// 1. Define your Zod schema\nconst ClassifyResult = z.object({\n  category: z.enum([\"bug\", \"feature\", \"question\"]),\n  confidence: z.number().min(0).max(1),\n  reasoning: z.string(),\n});\n\ntype ClassifyResult = z.infer<typeof ClassifyResult>;\n\n// 2. Mirror the schema in JSON Schema for the tool definition\nconst classifyTool = {\n  name: \"classify_ticket\",\n  description: \"Classify a support ticket into exactly one category with a confidence score.\",\n  input_schema: {\n    type: \"object\" as const,\n    properties: {\n      category: {\n        type: \"string\",\n        enum: [\"bug\", \"feature\", \"question\"],\n        description: \"The category that best fits the ticket.\",\n      },\n      confidence: {\n        type: \"number\",\n        description: \"Confidence score from 0 to 1.\",\n      },\n      reasoning: {\n        type: \"string\",\n        description: \"One sentence explaining the classification.\",\n      },\n    },\n    required: [\"category\", \"confidence\", \"reasoning\"],\n  },\n};\n\nasync function classifyTicket(text: string): Promise<ClassifyResult> {\n  const response = await client.messages.create({\n    model: \"claude-haiku-4-5-20251001\",\n    max_tokens: 512,\n    tools: [classifyTool],\n    // Force the model to call this specific tool\n    tool_choice: { type: \"tool\", name: \"classify_ticket\" },\n    messages: [\n      {\n        role: \"user\",\n        content: `Classify this support ticket: \"${text}\"`,\n      },\n    ],\n  });\n\n  // 3. Extract the tool_use block from the response\n  const toolUseBlock = response.content.find((b) => b.type === \"tool_use\");\n  if (!toolUseBlock || toolUseBlock.type !== \"tool_use\") {\n    throw new Error(\"Model did not return a tool_use block\");\n  }\n\n  // 4. Validate with Zod\n  const parsed = ClassifyResult.safeParse(toolUseBlock.input);\n  if (!parsed.success) {\n    throw new Error(`Schema violation: ${JSON.stringify(parsed.error.issues)}`);\n  }\n\n  return parsed.data;\n}\n```\n\nThe tool_choice parameter with `type: \"tool\"`\n\nis the key detail. Without it, the model may choose to answer in plain text. With it, the response always comes back as a structured `tool_use`\n\nblock.\n\nOne practical note: you define the schema twice here, once in Zod and once in JSON Schema. For small schemas that duplication is tolerable. For larger ones, look at `zod-to-json-schema`\n\non npm to generate the `input_schema`\n\nfrom your Zod definition automatically.\n\nEven with forced tool use and schema prompting, models occasionally return output that fails validation. Network hiccups, context window pressure, and edge-case inputs all produce unexpected shapes. Build the retry loop before you need it.\n\n```\nasync function classifyWithRetry(\n  text: string,\n  maxAttempts = 3\n): Promise<ClassifyResult> {\n  let lastError: unknown;\n\n  for (let attempt = 1; attempt <= maxAttempts; attempt++) {\n    try {\n      return await classifyTicket(text);\n    } catch (err) {\n      lastError = err;\n      console.warn(`[classify] attempt ${attempt} failed:`, err);\n\n      if (attempt < maxAttempts) {\n        // Small back-off: 500ms, 1000ms, ...\n        await new Promise((r) => setTimeout(r, attempt * 500));\n      }\n    }\n  }\n\n  throw new Error(`classify failed after ${maxAttempts} attempts: ${lastError}`);\n}\n```\n\nWhen the model returns a close-but-wrong shape, feeding the validation error back in a second call often works better than a blind retry. The model can see what it got wrong and correct it.\n\n```\nasync function classifyWithRepair(text: string): Promise<ClassifyResult> {\n  // First attempt\n  const response = await client.messages.create({\n    model: \"claude-haiku-4-5-20251001\",\n    max_tokens: 512,\n    tools: [classifyTool],\n    tool_choice: { type: \"tool\", name: \"classify_ticket\" },\n    messages: [{ role: \"user\", content: `Classify: \"${text}\"` }],\n  });\n\n  const toolBlock = response.content.find((b) => b.type === \"tool_use\");\n  const rawInput = toolBlock?.type === \"tool_use\" ? toolBlock.input : null;\n  const parsed = ClassifyResult.safeParse(rawInput);\n\n  if (parsed.success) {\n    return parsed.data;\n  }\n\n  // Repair attempt: show the model what it returned and what the schema expects\n  const repairMessages = [\n    { role: \"user\" as const, content: `Classify: \"${text}\"` },\n    {\n      role: \"assistant\" as const,\n      content: response.content,\n    },\n    {\n      role: \"user\" as const,\n      content: `Your output did not match the schema. Validation errors: ${JSON.stringify(parsed.error.issues)}. Please call classify_ticket again with a valid response.`,\n    },\n  ];\n\n  const repairResponse = await client.messages.create({\n    model: \"claude-haiku-4-5-20251001\",\n    max_tokens: 512,\n    tools: [classifyTool],\n    tool_choice: { type: \"tool\", name: \"classify_ticket\" },\n    messages: repairMessages,\n  });\n\n  const repairBlock = repairResponse.content.find((b) => b.type === \"tool_use\");\n  const repaired = ClassifyResult.safeParse(\n    repairBlock?.type === \"tool_use\" ? repairBlock.input : null\n  );\n\n  if (!repaired.success) {\n    throw new Error(`repair attempt failed: ${JSON.stringify(repaired.error.issues)}`);\n  }\n\n  return repaired.data;\n}\n```\n\nThe repair pattern costs one extra call but recovers most edge cases without manual intervention.\n\n``` js\nimport { z } from \"zod\";\n\nexport const IntentSchema = z.object({\n  intent: z.enum([\"search\", \"create\", \"delete\", \"status\", \"help\"]),\n  confidence: z.number().min(0).max(1),\n  extracted_entity: z.string().optional(),\n});\n\nexport type Intent = z.infer<typeof IntentSchema>;\n\n// JSON Schema for Anthropic tool use\nexport const intentToolSchema = {\n  type: \"object\" as const,\n  properties: {\n    intent: {\n      type: \"string\",\n      enum: [\"search\", \"create\", \"delete\", \"status\", \"help\"],\n    },\n    confidence: { type: \"number\" },\n    extracted_entity: { type: \"string\" },\n  },\n  required: [\"intent\", \"confidence\"],\n};\njs\nexport const ContactExtractSchema = z.object({\n  name: z.string(),\n  email: z.string().email().optional(),\n  phone: z.string().optional(),\n  company: z.string().optional(),\n  notes: z.string().optional(),\n});\n\nexport type ContactExtract = z.infer<typeof ContactExtractSchema>;\n\n// JSON Schema for Anthropic tool use\nexport const contactExtractToolSchema = {\n  type: \"object\" as const,\n  properties: {\n    name: { type: \"string\", description: \"Full name of the contact\" },\n    email: { type: \"string\", description: \"Email address if present\" },\n    phone: { type: \"string\", description: \"Phone number if present\" },\n    company: { type: \"string\", description: \"Company or organization if mentioned\" },\n    notes: { type: \"string\", description: \"Any other relevant details\" },\n  },\n  required: [\"name\"],\n};\n```\n\nUsage with the raw Anthropic SDK:\n\n``` js\nconst extractTool = {\n  name: \"extract_contact\",\n  description: \"Extract contact information from the provided text.\",\n  input_schema: contactExtractToolSchema,\n};\n\nconst response = await client.messages.create({\n  model: \"claude-haiku-4-5-20251001\",\n  max_tokens: 512,\n  tools: [extractTool],\n  tool_choice: { type: \"tool\", name: \"extract_contact\" },\n  messages: [\n    {\n      role: \"user\",\n      content: `Extract contact info from: \"Hi, I'm Sarah Chen at Acme Corp. Reach me at sarah@acme.io\"`,\n    },\n  ],\n});\n\nconst block = response.content.find((b) => b.type === \"tool_use\");\nconst result = ContactExtractSchema.safeParse(\n  block?.type === \"tool_use\" ? block.input : null\n);\n\nif (result.success) {\n  console.log(result.data.name);  // \"Sarah Chen\"\n  console.log(result.data.email); // \"sarah@acme.io\"\n}\n```\n\n`JSON.parse`\n\nwithout validation is a time bomb. The model will eventually return a shape you did not expect, and the failure mode is silent data corruption, not a loud error you can catch immediately.\n\nThe fix costs two things: a Zod schema (which you should write anyway for TypeScript types) and a `.safeParse()`\n\ncall instead of a raw `JSON.parse`\n\n. The Vercel AI SDK with `Output.object`\n\nhandles the wiring for you. The raw Anthropic SDK with `tool_choice`\n\nforced to a specific tool gives you the same guarantee with one extra setup step.\n\nThe retry and repair patterns are insurance. In practice, with `tool_choice`\n\nforced, parse failures happen in under 1% of calls for well-formed schemas. The 1% still matters when you are running 50,000 classifications a day.\n\nWhat shape does your most chaotic LLM output have right now? Drop it in the comments.\n\n**GDS K S** · [thegdsks.com](https://thegdsks.com) · follow on X [@thegdsks](https://x.com/thegdsks)\n\n*A Zod schema is the contract the model never gets to break.*", "url": "https://wpnews.pro/news/type-safe-llm-outputs-with-zod-stop-guessing-what-the-model-returns", "canonical_source": "https://dev.to/thegdsks/type-safe-llm-outputs-with-zod-stop-guessing-what-the-model-returns-544e", "published_at": "2026-07-15 03:34:13+00:00", "updated_at": "2026-07-15 03:56:58.409766+00:00", "lang": "en", "topics": ["large-language-models", "developer-tools", "ai-tools"], "entities": ["Zod", "Vercel AI SDK", "Anthropic SDK"], "alternates": {"html": "https://wpnews.pro/news/type-safe-llm-outputs-with-zod-stop-guessing-what-the-model-returns", "markdown": "https://wpnews.pro/news/type-safe-llm-outputs-with-zod-stop-guessing-what-the-model-returns.md", "text": "https://wpnews.pro/news/type-safe-llm-outputs-with-zod-stop-guessing-what-the-model-returns.txt", "jsonld": "https://wpnews.pro/news/type-safe-llm-outputs-with-zod-stop-guessing-what-the-model-returns.jsonld"}}