Back to all AI tutorials
Aug 20, 2025 - 15 min read
Building type-safe AI applications with structured outputs using the AI SDK and Zod

Building type-safe AI applications with structured outputs using the AI SDK and Zod

Learn how to use the AI SDK (v5) with Zod schemas to get structured, type-safe outputs from language models without parsing headaches.

Patrick the AI Engineer

Patrick the AI Engineer

You've asked an LLM to return JSON, and it came back with a string where you expected a number. Your app crashed. Or maybe it was missing a field entirely. You added try-catch blocks and validation, but every API call still feels like a gamble.

We're going to build a recipe generator that can't return malformed data. The AI SDK by Vercel (v5) with Zod schemas handles validation automatically. You define the shape once, and the model is constrained to match it during generation. No parsing code, no manual validation.

Let's start with Node.js and TypeScript, then we'll add streaming and show how Vue makes the UI reactive.

First, install the dependencies:

npm init -y
npm i ai @ai-sdk/openai zod
npm i --save-dev @types/node typescript tsx dotenv

Your package.json needs this:

{
  "type": "module",
  "scripts": {
    "dev": "tsx src/index.ts"
  }
}

Add your OpenAI key to .env:

OPENAI_API_KEY=sk-your-key-here

Now let's define what a recipe looks like:

// src/schemas.ts
import { z } from 'zod'

export const recipeSchema = z.object({
  name: z.string(),
  cuisine: z.string(),
  difficulty: z.enum(['easy', 'medium', 'hard'])
})

That's a Zod schema. It defines the shape and types we expect. The .enum() constrains difficulty to specific values.

Let's add time fields:

export const recipeSchema = z.object({
  name: z.string(),
  cuisine: z.string(),
  difficulty: z.enum(['easy', 'medium', 'hard']),
  prepTime: z.number().describe('Prep time in minutes'),
  cookTime: z.number().describe('Cook time in minutes'),
  servings: z.number()
})

The .describe() calls guide the AI by explaining what each field represents. They're not just documentation, they affect output quality.

Now we need ingredients:

const ingredientSchema = z.object({
  name: z.string(),
  amount: z.string().describe('Amount with unit (e.g., "2 cups")'),
  category: z.enum(['protein', 'vegetable', 'grain', 'dairy', 'spice', 'other'])
})

export const recipeSchema = z.object({
  name: z.string(),
  cuisine: z.string(),
  difficulty: z.enum(['easy', 'medium', 'hard']),
  prepTime: z.number().describe('Prep time in minutes'),
  cookTime: z.number().describe('Cook time in minutes'),
  servings: z.number(),
  ingredients: z.array(ingredientSchema)
})

Arrays work naturally. The model generates variable-length lists that match the inner schema.

Let's add instructions and nutrition info:

export const recipeSchema = z.object({
  name: z.string(),
  cuisine: z.string(),
  difficulty: z.enum(['easy', 'medium', 'hard']),
  prepTime: z.number().describe('Prep time in minutes'),
  cookTime: z.number().describe('Cook time in minutes'),
  servings: z.number(),
  ingredients: z.array(ingredientSchema),
  instructions: z.array(z.string()),
  nutrition: z.object({
    calories: z.number(),
    protein: z.number().describe('Protein in grams'),
    carbs: z.number(),
    fat: z.number()
  })
})

export type Recipe = z.infer<typeof recipeSchema>

That last line gives us a TypeScript type derived from the schema. Our types and validation are always in sync.

Now let's generate a recipe:

// src/generator.ts
import { generateObject } from 'ai'
import { openai } from '@ai-sdk/openai'
import { recipeSchema } from './schemas.js'

export async function generateRecipe(prompt: string) {
  const { object } = await generateObject({
    model: openai('gpt-4o-mini'),
    schema: recipeSchema,
    prompt: `Create a recipe for: ${prompt}`
  })
  
  return object
}

That's it. No parsing, no validation code. The object is fully typed and guaranteed to match the schema. The AI SDK converts your Zod schema to JSON Schema, sends it to the model, and validates the response before returning it.

Let's add a system prompt for better results:

export async function generateRecipe(prompt: string) {
  const { object } = await generateObject({
    model: openai('gpt-4o-mini'),
    schema: recipeSchema,
    system: `You are a professional chef. Generate detailed recipes 
      with precise measurements and realistic nutritional information.`,
    prompt: `Create a recipe for: ${prompt}`,
    temperature: 0.7
  })
  
  return object
}

Temperature controls creativity. At 0.7 we get varied recipes that still follow the structure. At 0.0 it's more consistent but repetitive. At 1.0 it's creative but might push against constraints.

Error handling catches both API and validation failures:

import { z } from 'zod'

export async function generateRecipe(prompt: string) {
  try {
    const { object } = await generateObject({
      model: openai('gpt-4o-mini'),
      schema: recipeSchema,
      system: `You are a professional chef...`,
      prompt: `Create a recipe for: ${prompt}`,
      temperature: 0.7
    })
    
    return object
  } catch (error) {
    if (error instanceof z.ZodError) {
      console.error('Schema validation failed:', error.errors)
      throw new Error('Invalid recipe structure')
    }
    throw error
  }
}

Zod errors give you detailed paths to problematic fields. This rarely happens with structured outputs, but it's good to handle.

Let's use it:

// src/index.ts
import 'dotenv/config'
import { generateRecipe } from './generator.js'

const recipe = await generateRecipe('creamy mushroom pasta')

console.log(`${recipe.name}`)
console.log(`⏱️  ${recipe.prepTime + recipe.cookTime} min total`)
console.log(`🍽️  Serves ${recipe.servings}`)
console.log(`📊 ${recipe.nutrition.calories} cal per serving`)

Run it with npm run dev. You get a complete recipe with full type safety and autocomplete throughout. No type assertions needed.

The ingredients array is fully typed:

console.log('\nIngredients:')
recipe.ingredients.forEach(ing => {
  console.log(`- ${ing.amount} ${ing.name}`)
})

TypeScript knows ing has name, amount, and category properties. If you typo a property name, you'll get a compile error.

Streaming the Output

For better UX, we can stream the recipe as it generates:

// src/streaming.ts
import { streamObject } from 'ai'
import { openai } from '@ai-sdk/openai'
import { recipeSchema } from './schemas.js'

export async function streamRecipe(prompt: string) {
  const { partialObjectStream, object } = streamObject({
    model: openai('gpt-4o-mini'),
    schema: recipeSchema,
    prompt: `Generate a recipe for: ${prompt}`
  })
  
  for await (const partial of partialObjectStream) {
    console.log('Generating...')
  }
  
  return await object
}

The partialObjectStream gives us incomplete objects as they arrive. Fields might be undefined if they haven't been generated yet.

Let's show progress as fields populate:

export async function streamRecipe(prompt: string) {
  const { partialObjectStream, object } = streamObject({
    model: openai('gpt-4o-mini'),
    schema: recipeSchema,
    prompt: `Generate a recipe for: ${prompt}`
  })
  
  for await (const partial of partialObjectStream) {
    console.clear()
    console.log('🍳 Generating recipe...\n')
    
    if (partial.name) {
      console.log(`📝 ${partial.name}`)
    }
    if (partial.ingredients) {
      console.log(`🥕 ${partial.ingredients.length} ingredients`)
    }
    if (partial.instructions) {
      console.log(`👨‍🍳 ${partial.instructions.length} steps`)
    }
  }
  
  return await object
}

The terminal updates as more fields appear. The final object promise resolves when generation completes with the fully validated recipe.

Generating Multiple Recipes

You can generate arrays by setting output: 'array':

// src/collections.ts
import { generateObject } from 'ai'
import { openai } from '@ai-sdk/openai'
import { recipeSchema } from './schemas.js'

export async function generateCollection(theme: string) {
  const { object } = await generateObject({
    model: openai('gpt-4o-mini'),
    output: 'array',
    schema: recipeSchema,
    prompt: `Generate 3 different ${theme} recipes with varying difficulty`
  })
  
  return object
}

The AI SDK handles array generation. You get back a typed array of recipe objects.

For streaming arrays, use elementStream:

export async function streamCollection(theme: string) {
  const { elementStream } = streamObject({
    model: openai('gpt-4o-mini'),
    output: 'array',
    schema: recipeSchema,
    prompt: `Generate 5 ${theme} recipes`
  })
  
  const recipes = []
  for await (const recipe of elementStream) {
    console.log(`${recipe.name}`)
    recipes.push(recipe)
  }
  
  return recipes
}

Each complete element arrives as it's generated. Great for showing progressive results in a UI.

Building a Vue Component

Vue makes streaming updates elegant. Let's build a recipe generator:

<script setup lang="ts">
import { ref } from 'vue'
import { streamObject } from 'ai'
import { openai } from '@ai-sdk/openai'
import { recipeSchema, type Recipe } from './schemas'

const prompt = ref('')
const recipe = ref<Partial<Recipe>>({})
const isGenerating = ref(false)

The Partial<Recipe> type means all fields are optional. This handles the partial updates from streaming.

Wire up the generation:

async function generate() {
  if (!prompt.value) return
  
  isGenerating.value = true
  recipe.value = {}
  
  const { partialObjectStream, object } = streamObject({
    model: openai('gpt-4o-mini'),
    schema: recipeSchema,
    prompt: `Create a recipe for: ${prompt.value}`
  })
  
  for await (const partial of partialObjectStream) {
    recipe.value = partial
  }
  
  recipe.value = await object
  isGenerating.value = false
}
</script>

As partial objects arrive, Vue's reactivity updates the UI automatically. The template can show fields as they populate:

<template>
  <div class="recipe-generator">
    <input 
      v-model="prompt" 
      placeholder="What recipe do you want?"
      @keyup.enter="generate"
    />
    <button @click="generate" :disabled="isGenerating">
      {{ isGenerating ? 'Generating...' : 'Generate Recipe' }}
    </button>
    
    <div v-if="recipe.name" class="recipe">
      <h2>{{ recipe.name }}</h2>
      
      <div v-if="recipe.prepTime" class="meta">
        ⏱️ {{ recipe.prepTime + (recipe.cookTime || 0) }} min
      </div>
    </div>
  </div>
</template>

Fields appear as they're generated. Vue re-renders when recipe.value updates. The optional chaining (recipe.cookTime || 0) handles partial states gracefully.

Show ingredients progressively:

      <div v-if="recipe.ingredients?.length" class="ingredients">
        <h3>Ingredients ({{ recipe.ingredients.length }})</h3>
        <ul>
          <li v-for="ing in recipe.ingredients" :key="ing.name">
            {{ ing.amount }} {{ ing.name }}
          </li>
        </ul>
      </div>

The list grows as ingredients are generated. No loading skeletons needed, content appears naturally.

Add instructions:

      <div v-if="recipe.instructions?.length" class="instructions">
        <h3>Instructions</h3>
        <ol>
          <li v-for="(step, i) in recipe.instructions" :key="i">
            {{ step }}
          </li>
        </ol>
      </div>

Same pattern. Steps appear one by one as the AI generates them.

For multiple recipes, use a reactive array:

<script setup lang="ts">
const recipes = ref<Recipe[]>([])

async function generateMultiple(theme: string) {
  recipes.value = []
  
  const { elementStream } = streamObject({
    model: openai('gpt-4o-mini'),
    output: 'array',
    schema: recipeSchema,
    prompt: `Generate 5 ${theme} recipes`
  })
  
  for await (const recipe of elementStream) {
    recipes.value.push(recipe)
  }
}
</script>

Each new recipe appears in the list as it's generated:

<template>
  <div class="recipe-grid">
    <div 
      v-for="recipe in recipes" 
      :key="recipe.name"
      class="recipe-card"
    >
      <h3>{{ recipe.name }}</h3>
      <p>{{ recipe.cuisine }}{{ recipe.difficulty }}</p>
    </div>
  </div>
</template>

Vue handles the DOM updates. Your code just pushes to the array.

Things to Keep in Mind

Structured outputs work best with models that support JSON mode natively. OpenAI's GPT-4 and GPT-4o have native support. Older models fall back to best-effort parsing, which is less reliable.

Token costs are slightly higher because the JSON Schema adds to the prompt. For complex schemas with many nested objects, this can be 10-20% more tokens. Worth it for the reliability.

Schema descriptions affect output quality significantly. "name" vs "name of the recipe including cuisine style" produces different results. Treat descriptions like prompt engineering for individual fields.

If you make fields optional that shouldn't be, the model might skip them. If you require too many specific values, the model struggles to be creative. Balance constraint with flexibility.

Wrapping Up

We built a type-safe recipe generator where the AI can't return malformed data. Zod schemas define both your TypeScript types and runtime validation in one place. The AI SDK translates those schemas into constraints the model understands. You get fully typed results or an error, never malformed data that crashes later.

Full Code Examples

import { z } from 'zod'

const ingredientSchema = z.object({
  name: z.string(),
  amount: z.string().describe('Amount with unit (e.g., "2 cups")'),
  category: z.enum(['protein', 'vegetable', 'grain', 'dairy', 'spice', 'other'])
})

export const recipeSchema = z.object({
  name: z.string().describe('The name of the recipe'),
  cuisine: z.string().describe('The cuisine type'),
  difficulty: z.enum(['easy', 'medium', 'hard']),
  prepTime: z.number().describe('Prep time in minutes'),
  cookTime: z.number().describe('Cook time in minutes'),
  servings: z.number(),
  ingredients: z.array(ingredientSchema)
    .describe('List of ingredients needed'),
  instructions: z.array(z.string())
    .describe('Step-by-step cooking instructions'),
  nutrition: z.object({
    calories: z.number(),
    protein: z.number().describe('Protein in grams per serving'),
    carbs: z.number(),
    fat: z.number()
  }).describe('Nutritional information per serving')
})

export type Recipe = z.infer<typeof recipeSchema>
Patrick the AI Engineer

Ready to innovate with AI?

I build intelligent applications that solve business problems for forward-thinking companies. I'm also available to share my expertise through conference talks and hands-on AI workshops. If you're ready to bring your ideas to life, let's connect.

Copyright © 2025