TypeScript AI Structured Outputs

A practical reference for generating guaranteed type-safe JSON data from AI models using Zod schemas and structured output modes.

TL;DR

  1. Define expected data structures using z.object() and Zod primitives.
  2. Derive static TypeScript types directly from runtime schemas with z.infer<typeof Schema>.
  3. Enforce JSON output schemas with native provider response_format flags.

Schema Definition with Zod

    z.object() definition

    Declare structured payload shape using Zod schema builders.

    import { z } from 'zod';
    const UserSchema = z.object({
      name: z.string().describe('Full user name'),
      age: z.number().int().positive(),
      roles: z.array(z.enum(['admin', 'editor'])),
    });
    z.infer<typeof Schema>

    Derive static TypeScript types directly from runtime schemas.

    type User = z.infer<typeof UserSchema>;
    // Inferred type:
    // {
    //   name: string;
    //   age: number;
    //   roles: ('admin' | 'editor')[];
    // }
    Optional and nullable fields

    Model optional properties and nullable attributes cleanly.

    const PostSchema = z.object({
      title: z.string(),
      summary: z.string().optional(),
      publishedAt: z.string().datetime().nullable(),
    });

Native Provider Configuration

    OpenAI json_schema mode

    Enforce strict JSON schema compliance with OpenAI models.

    import { zodResponseFormat } from 'openai/helpers/zod';
    const res = await openai.chat.completions.create({
      model: 'gpt-4o',
      messages: [{ role: 'user', content: 'Extract' }],
      response_format: zodResponseFormat(UserSchema, 'u'),
    });
    Gemini responseSchema

    Configure schema constraints in Google GenAI client.

    const res = await ai.models.generateContent({
      model: 'gemini-2.0-flash',
      contents: 'Extract user info',
      config: {
        responseMimeType: 'application/json',
        responseSchema: { type: 'OBJECT' },
      },
    });
    Anthropic tool constraint

    Simulate structured outputs in Claude using tool choice.

    const res = await anthropic.messages.create({
      model: 'claude-3-5-sonnet-20241022',
      max_tokens: 500,
      tools: [{
        name: 'save_user',
        description: 'Save user',
        input_schema: { type: 'object' },
      }],
      tool_choice: { type: 'tool', name: 'save_user' },
      messages: [{ role: 'user', content: 'Extract' }],
    });

Safe Parsing and Type Narrowing

    Schema.safeParse()

    Validate raw JSON string safely without throwing exceptions.

    const parsed = UserSchema.safeParse(JSON.parse(raw));
    if (!parsed.success) {
      console.error(parsed.error.issues);
    } else {
      const user: User = parsed.data;
      console.log('User:', user.name);
    }
    Format Zod errors

    Extract readable validation messages for user feedback.

    function formatErrors(err: z.ZodError): string {
      return err.issues
        .map(i => `${i.path.join('.')}: ${i.message}`)
        .join(', ');
    }
    Type-safe wrapper function

    Create reusable generic helper to extract typed objects.

    async function extractObject<T>(
      schema: z.ZodType<T>, prompt: string
    ): Promise<T> {
      const raw = await fetchCompletion(prompt);
      return schema.parse(JSON.parse(raw));
    }

Automated Schema Self-Repair

    Self-repair prompt loop

    Re-prompt model with validation error details on failure.

    async function repairJson<T>(
      schema: z.ZodType<T>, bad: string, err: string
    ): Promise<T> {
      const res = await openai.chat.completions.create({
        model: 'gpt-4o',
        messages: [
          { role: 'user', content: `${bad}: ${err}` },
        ],
      });
      const txt = res.choices[0].message.content!;
      return schema.parse(JSON.parse(txt));
    }
    Retry with backoff loop

    Attempt parsing up to three times before throwing error.

    for (let i = 0; i < 3; i++) {
      try {
        return await extractObject(UserSchema, prompt);
      } catch {}
    }
    throw new Error('Retries failed');
    Schema default values

    Provide fallback defaults for non-critical attributes.

    const SettingsSchema = z.object({
      theme: z.enum(['light', 'dark']).default('light'),
      retries: z.number().default(3),
    });

Tips

  1. Use z.infer<typeof Schema> to keep your compile-time TypeScript interfaces perfectly synchronized with your runtime model validation rules.
  2. Attach .describe() helper annotations to your Zod schema keys to provide contextual instructions that guide the model during extraction.

Warnings

  1. Never assume an AI model response is valid JSON without wrapping JSON.parse and .safeParse() inside defensive error handlers.
  2. Avoid overly deep nested object schemas because recursive validation constraints can cause models to exhaust their max_tokens budget.

In Practice

FAQ