Docs
CodeRabbit
Cloudflare
AG Grid
SerpAPI
Netlify
OpenRouter
Neon
WorkOS
Clerk
Electric
PowerSync
Sentry
Railway
Prisma
Strapi
Unkey
CodeRabbit
Cloudflare
AG Grid
SerpAPI
Netlify
OpenRouter
Neon
WorkOS
Clerk
Electric
PowerSync
Sentry
Railway
Prisma
Strapi
Unkey
Class References
Function References
Interface References
Type Alias References
Variable References
Structured Outputs

One-Shot Extraction

You have unstructured input — a paragraph of text, a freeform user prompt, the body of an email — and you want exactly one typed object back. No streaming, no history, no agent loop: one prompt in, one validated object out.

By the end of this guide you'll have a working chat({ outputSchema }) call returning a fully-typed result, know how to describe fields so the model fills them correctly, and have a pattern for handling validation errors.

Note: If you want to stream the result field-by-field into a UI, you want Streaming UIs instead. If you want users to iterate on the object across multiple turns, you want Multi-Turn Chat. This page is for the single-extraction case.

Basic Usage

Define a schema and pass it as outputSchema. The return type follows from the schema — no cast needed.

typescript
import { chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { z } from "zod";

const PersonSchema = z.object({
  name: z.string().meta({ description: "The person's full name" }),
  age: z.number().meta({ description: "The person's age in years" }),
  email: z.string().email().meta({ description: "The person's email address" }),
});

const person = await chat({
  adapter: openaiText("gpt-5.2"),
  messages: [
    {
      role: "user",
      content:
        "Extract the person info: John Doe is 30 years old, email john@example.com",
    },
  ],
  outputSchema: PersonSchema,
});

person.name;  // string
person.age;   // number
person.email; // string
import { chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { z } from "zod";

const PersonSchema = z.object({
  name: z.string().meta({ description: "The person's full name" }),
  age: z.number().meta({ description: "The person's age in years" }),
  email: z.string().email().meta({ description: "The person's email address" }),
});

const person = await chat({
  adapter: openaiText("gpt-5.2"),
  messages: [
    {
      role: "user",
      content:
        "Extract the person info: John Doe is 30 years old, email john@example.com",
    },
  ],
  outputSchema: PersonSchema,
});

person.name;  // string
person.age;   // number
person.email; // string

Type Inference

The return type of chat() switches on the combination of outputSchema and stream:

ConfigurationReturn type
No outputSchema, stream: falsePromise<string>
No outputSchema, stream: true (default for plain chat)AsyncIterable<StreamChunk>
With outputSchema (this page — implicitly non-streaming)Promise<InferSchemaType<TSchema>>
With outputSchema and stream: trueStructuredOutputStream<InferSchemaType<TSchema>> (see Streaming UIs)

The TypeScript type of person above is { name: string; age: number; email: string } — derived from PersonSchema. No runtime cast, no as, no separate type definition.

Field Descriptions

Field descriptions tell the model what data to extract. They become part of the JSON Schema sent to the provider — the model sees them as hints. In Zod 4+ use .meta():

typescript
const ProductSchema = z.object({
  name: z.string().meta({ description: "The product name" }),
  price: z.number().meta({ description: "Price in USD" }),
  inStock: z.boolean().meta({
    description: "Whether the product is currently available",
  }),
  categories: z
    .array(z.string())
    .meta({
      description:
        "Product categories like 'electronics', 'clothing', etc.",
    }),
});
const ProductSchema = z.object({
  name: z.string().meta({ description: "The product name" }),
  price: z.number().meta({ description: "Price in USD" }),
  inStock: z.boolean().meta({
    description: "Whether the product is currently available",
  }),
  categories: z
    .array(z.string())
    .meta({
      description:
        "Product categories like 'electronics', 'clothing', etc.",
    }),
});

Descriptions earn their keep when:

  • The field name is ambiguous (price — in what currency?)
  • The expected unit isn't obvious (duration — seconds or minutes?)
  • The schema is being applied to text where the same concept could be phrased many ways

Complex Nested Schemas

Schemas can nest arbitrarily. The inferred type follows the structure.

typescript
const CompanySchema = z.object({
  name: z.string(),
  founded: z.number().meta({ description: "Year the company was founded" }),
  headquarters: z.object({
    city: z.string(),
    country: z.string(),
    address: z.string().optional(),
  }),
  employees: z.array(
    z.object({
      name: z.string(),
      role: z.string(),
      department: z.string(),
    }),
  ),
  financials: z
    .object({
      revenue: z.number().meta({ description: "Annual revenue in millions USD" }),
      profitable: z.boolean(),
    })
    .optional(),
});

const company = await chat({
  adapter: anthropicText("claude-sonnet-4-5"),
  messages: [{ role: "user", content: "Extract company info from this article: ..." }],
  outputSchema: CompanySchema,
});

company.headquarters.city; // string
company.employees[0].role; // string
company.financials?.profitable; // boolean | undefined
const CompanySchema = z.object({
  name: z.string(),
  founded: z.number().meta({ description: "Year the company was founded" }),
  headquarters: z.object({
    city: z.string(),
    country: z.string(),
    address: z.string().optional(),
  }),
  employees: z.array(
    z.object({
      name: z.string(),
      role: z.string(),
      department: z.string(),
    }),
  ),
  financials: z
    .object({
      revenue: z.number().meta({ description: "Annual revenue in millions USD" }),
      profitable: z.boolean(),
    })
    .optional(),
});

const company = await chat({
  adapter: anthropicText("claude-sonnet-4-5"),
  messages: [{ role: "user", content: "Extract company info from this article: ..." }],
  outputSchema: CompanySchema,
});

company.headquarters.city; // string
company.employees[0].role; // string
company.financials?.profitable; // boolean | undefined

Using Plain JSON Schema

If you don't want a schema library, pass a JSON Schema object directly. The trade-off: TypeScript can't infer the return type, so the result is unknown and you take responsibility for the runtime shape.

typescript
import type { JSONSchema } from "@tanstack/ai";

const schema: JSONSchema = {
  type: "object",
  properties: {
    name: { type: "string", description: "The person's name" },
    age: { type: "number", description: "The person's age" },
  },
  required: ["name", "age"],
};

const result = await chat({
  adapter: openaiText("gpt-5.2"),
  messages: [{ role: "user", content: "Extract: John is 25 years old" }],
  outputSchema: schema,
});

// Result is `unknown` — narrow before use.
const person = result as { name: string; age: number };
import type { JSONSchema } from "@tanstack/ai";

const schema: JSONSchema = {
  type: "object",
  properties: {
    name: { type: "string", description: "The person's name" },
    age: { type: "number", description: "The person's age" },
  },
  required: ["name", "age"],
};

const result = await chat({
  adapter: openaiText("gpt-5.2"),
  messages: [{ role: "user", content: "Extract: John is 25 years old" }],
  outputSchema: schema,
});

// Result is `unknown` — narrow before use.
const person = result as { name: string; age: number };

Prefer a schema library when you can — type inference is worth it.

Error Handling

If the model's response doesn't satisfy your schema, chat() throws a validation error. The message includes the failing fields.

typescript
try {
  const result = await chat({
    adapter: openaiText("gpt-5.2"),
    messages: [{ role: "user", content: "..." }],
    outputSchema: MySchema,
  });
} catch (error) {
  if (error instanceof Error) {
    console.error("Structured output failed:", error.message);
    // The message names which fields failed validation.
  }
}
try {
  const result = await chat({
    adapter: openaiText("gpt-5.2"),
    messages: [{ role: "user", content: "..." }],
    outputSchema: MySchema,
  });
} catch (error) {
  if (error instanceof Error) {
    console.error("Structured output failed:", error.message);
    // The message names which fields failed validation.
  }
}

Provider-level errors (auth failure, rate limit, network) throw the same way — wrap the call in try / catch to handle both.

Best Practices

  1. Use descriptive field names and descriptions. The model uses them as hints.

  2. Keep schemas focused. Extract only what you need — smaller schemas produce more reliable results.

  3. Mark fields optional when they really are. Don't force the model to invent a value just because the schema demands one.

  4. Use enums for constrained values.

    typescript
    const schema = z.object({
      status: z.enum(["pending", "approved", "rejected"]),
      priority: z.enum(["low", "medium", "high"]),
    });
    const schema = z.object({
      status: z.enum(["pending", "approved", "rejected"]),
      priority: z.enum(["low", "medium", "high"]),
    });
  5. Test edge cases. Empty inputs, ambiguous inputs, inputs with extra fields — make sure the schema handles them the way you expect.