Skip to content

Structured Output ​

Get type-safe, validated responses from any LLM provider with Zod schemas. The library handles schema conversion, provider-specific formatting, response parsing, and validation automatically.

Overview ​

Three methods for structured output:

MethodThrows on failureReturns
generateStructured()✅T
tryParseStructured()❌Result<T>
generateStructuredStream()✅ (on final)AsyncGenerator<T>

generateStructured ​

The primary method — sends your Zod schema to the LLM, parses the JSON response, and validates it against the schema. Returns a fully typed object.

typescript
import { AIModel } from 'universal-llm-client';
import { z } from 'zod';

const model = new AIModel({
  model: 'gpt-4o-mini',
  providers: [{ type: 'openai', apiKey: process.env.OPENAI_API_KEY }],
});

const SentimentSchema = z.object({
  sentiment: z.enum(['positive', 'negative', 'neutral']),
  confidence: z.number().min(0).max(1),
  reasoning: z.string(),
});

const result = await model.generateStructured(SentimentSchema, [
  { role: 'user', content: 'Analyze: "This product is amazing!"' },
]);

// result is typed as { sentiment: 'positive' | 'negative' | 'neutral', confidence: number, reasoning: string }
console.log(result.sentiment);  // "positive"
console.log(result.confidence); // 0.95

tryParseStructured ​

Non-throwing variant that returns a discriminated union Result<T>:

typescript
const result = await model.tryParseStructured(SentimentSchema, [
  { role: 'user', content: 'Analyze this text...' },
]);

if (result.ok) {
  // TypeScript narrows to { ok: true, value: T }
  console.log(result.value.sentiment);
} else {
  // TypeScript narrows to { ok: false, error: StructuredOutputError, rawOutput: string }
  console.error('Failed:', result.error.message);
  console.error('Raw LLM output:', result.rawOutput);
}

generateStructuredStream ​

Stream partial validated objects as the LLM generates JSON token by token:

typescript
const ArticleSchema = z.object({
  title: z.string(),
  summary: z.string(),
  tags: z.array(z.string()),
});

const stream = model.generateStructuredStream(ArticleSchema, [
  { role: 'user', content: 'Write an article about TypeScript' },
]);

for await (const partial of stream) {
  // partial is progressively filled as JSON streams in
  console.log(partial);
  // First:  { title: "TypeScript..." }
  // Then:   { title: "TypeScript...", summary: "A deep dive..." }
  // Final:  { title: "...", summary: "...", tags: ["typescript", "programming"] }
}

INFO

Partial yields are best-effort — only yielded when the partial JSON parses and validates successfully. The final object is always fully validated.

Inline via chat() Options ​

You can also pass structured output config directly to chat():

typescript
const response = await model.chat(
  [{ role: 'user', content: 'Generate a user profile' }],
  {
    output: {
      schema: UserSchema,
      name: 'user_profile',        // optional: hint for the LLM
      description: 'A user profile', // optional: hint for the LLM
    },
  },
);

// The response content will be the validated JSON
const user = JSON.parse(response.message.content as string);

Error Handling ​

When validation fails, a StructuredOutputError is thrown with full debugging context:

typescript
import { StructuredOutputError } from 'universal-llm-client';

try {
  const result = await model.generateStructured(StrictSchema, messages);
} catch (error) {
  if (error instanceof StructuredOutputError) {
    // The raw text the LLM returned (before parsing failed)
    console.log('Raw output:', error.rawOutput);

    // The underlying cause — ZodError for validation failures, SyntaxError for JSON parse errors
    if (error.cause instanceof z.ZodError) {
      console.log('Validation issues:', error.cause.issues);
    }
  }
}

Error types ​

CauseScenario
SyntaxErrorLLM returned non-JSON text
z.ZodErrorJSON parsed but doesn't match schema

Schema Tips ​

Use .describe() for better LLM guidance ​

typescript
const ReviewSchema = z.object({
  rating: z.number().min(1).max(5).describe('Rating from 1 (worst) to 5 (best)'),
  pros: z.array(z.string()).describe('List of positive aspects'),
  cons: z.array(z.string()).describe('List of negative aspects'),
});

Optional fields with defaults ​

typescript
const ConfigSchema = z.object({
  name: z.string(),
  debug: z.boolean().default(false),
  retries: z.number().default(3),
});

Enums for constrained values ​

typescript
const ClassificationSchema = z.object({
  category: z.enum(['bug', 'feature', 'question', 'docs']),
  priority: z.enum(['low', 'medium', 'high', 'critical']),
});

Nested objects ​

typescript
const CompanySchema = z.object({
  name: z.string(),
  ceo: z.object({
    name: z.string(),
    age: z.number(),
  }),
  offices: z.array(z.object({
    city: z.string(),
    country: z.string(),
    employees: z.number(),
  })),
});

Raw JSON Schema ​

If you don't want to use Zod, you can pass a raw JSON Schema directly:

typescript
const response = await model.chat(messages, {
  output: {
    jsonSchema: {
      type: 'object',
      properties: {
        name: { type: 'string' },
        age: { type: 'number' },
      },
      required: ['name', 'age'],
    },
    name: 'user',
  },
});

WARNING

When using raw JSON Schema, responses are not type-checked at compile time and not validated at runtime by the Router. Use Zod schemas for full type safety.

OpenAI Strict Mode ​

OpenAI supports a strict mode that guarantees the response matches the schema exactly. This is enabled by default:

typescript
const result = await model.generateStructured(MySchema, messages, {
  output: { strict: true }, // default for OpenAI
});

Set strict: false if you're using an OpenAI-compatible endpoint that doesn't support strict mode.

Combining with Tools ​

Structured output and tool calling can be used together. When both are provided:

  • The provider sends both the schema constraint and tool definitions in the request
  • If the model responds with content, it's validated against the schema as usual
  • If the model responds with tool calls, schema validation is skipped and the tool calling loop handles the response
typescript
const result = await model.chat(messages, {
  output: { schema: ResultSchema },
  tools: [myTool],
});

// result.structured is set if the model returned content
// result.message.tool_calls is set if the model invoked tools

Limitations ​

  • Streaming — use generateStructuredStream() instead of chatStream() with output options
  • Provider support — all built-in providers support structured output, but behavior varies (see Providers)

Released under the MIT License.