본문으로 건너뛰기

단일 추출

구조화되지 않은 입력(텍스트 단락, 자유 형식의 사용자 프롬프트, 이메일 본문)이 있고 타입이 지정된 객체 하나만 반환받고 싶을 수 있습니다. 스트리밍도, 기록도, 에이전트 루프도 없습니다. 프롬프트 하나를 입력하고 검증된 객체 하나를 출력합니다.

이 가이드를 마치면 완전히 타입이 지정된 결과를 반환하는 chat({ outputSchema }) 호출을 사용할 수 있고, 모델이 필드를 올바르게 채우도록 설명하는 방법과 검증 오류를 처리하는 패턴을 익히게 됩니다.

참고: 결과를 필드별로 UI에 스트리밍하려면 Streaming UIs를 사용합니다. 여러 턴에 걸쳐 객체를 반복해서 다루려면 Multi-Turn Chat을 사용합니다. 모델이 먼저 샌드박스에서 파일을 검사해야 한다면 Harness Agents를 사용합니다. 이 페이지는 단일 추출 사례를 다룹니다.

기본 사용법​

스키마를 정의하고 outputSchema로 전달합니다. 반환 타입은 스키마에서 추론되므로 캐스트가 필요하지 않습니다.

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.5"),
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

타입 추론​

chat()의 반환 타입은 outputSchema와 stream의 조합에 따라 달라집니다.

구성반환 타입
outputSchema 없음, stream: falsePromise<string>
outputSchema 없음, stream: true (일반 chat의 기본값)AsyncIterable<StreamChunk>
outputSchema 사용(이 페이지—암시적으로 비스트리밍)Promise<InferSchemaType<TSchema>>
outputSchema와 stream: true 사용StructuredOutputStream<InferSchemaType<TSchema>> (Streaming UIs 참고)

위 person의 TypeScript 타입은 PersonSchema에서 파생된 { name: string; age: number; email: string }입니다. 런타임 캐스트도, as도, 별도의 타입 정의도 필요하지 않습니다.

필드 설명​

필드 설명은 추출할 데이터를 모델에 알려줍니다. 필드 설명은 provider로 전송되는 JSON Schema의 일부가 되며, 모델은 이를 힌트로 사용합니다. Zod v4.2 이상에서는 .meta()를 사용합니다.

import { z } from "zod";

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.",
}),
});

설명은 다음과 같은 경우 특히 유용합니다.

  • 필드 이름이 모호한 경우(price—어떤 통화인가요?)
  • 예상 단위가 명확하지 않은 경우(duration—초인가요, 분인가요?)
  • 같은 개념을 여러 방식으로 표현할 수 있는 텍스트에 스키마를 적용하는 경우

복잡한 중첩 스키마​

스키마는 임의의 깊이로 중첩할 수 있습니다. 추론된 타입은 그 구조를 따릅니다.

import { chat } from "@tanstack/ai";
import { anthropicText } from "@tanstack/ai-anthropic";
import { z } from "zod";

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-6"),
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

일반 JSON Schema 사용​

스키마 라이브러리를 사용하지 않으려면 JSON Schema 객체를 직접 전달합니다. 대신 TypeScript가 반환 타입을 추론할 수 없으므로 결과는 unknown이 되며, 런타임 형태를 직접 책임져야 합니다.

import { chat } from "@tanstack/ai";
import type { JSONSchema } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";

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.5"),
messages: [{ role: "user", content: "Extract: John is 25 years old" }],
outputSchema: schema,
});

// `result` is `unknown` — a raw JSON Schema gives no compile-time type.
// Validate it (e.g. with a Standard Schema library) before use.

가능하면 스키마 라이브러리를 사용하는 것이 좋습니다. 타입 추론의 이점이 충분히 큽니다.

오류 처리​

모델의 응답이 스키마를 충족하지 않으면 chat()이 검증 오류를 발생시킵니다. 메시지에는 검증에 실패한 필드가 포함됩니다.

import { chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { MySchema } from "./schemas";

try {
const result = await chat({
adapter: openaiText("gpt-5.5"),
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 수준의 오류(인증 실패, 속도 제한, 네트워크 오류)도 같은 방식으로 발생하므로, 두 종류의 오류를 모두 처리하려면 호출을 try / catch로 감쌉니다.

클라이언트에서 결과 사용​

위의 await chat({ outputSchema }) 호출은 Promise<T>를 반환하므로 서버 라우트, 스크립트 또는 CLI에 적합합니다. 타입이 지정된 객체를 브라우저로 전달하는 방법은 두 가지입니다.

일반 JSON으로 사용(훅 없음)​

클라이언트에 완성된 객체만 필요하고 점진적인 UI가 필요하지 않다면 서버에서 promise를 resolve하고 JSON으로 반환합니다. 브라우저는 다른 엔드포인트와 같은 방식으로 가져옵니다. TanStack 클라이언트 API도, partial / final도 필요하지 않습니다.

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

const PersonSchema = z.object({
name: z.string(),
age: z.number(),
email: z.string().email(),
});

// server route
export async function POST(request: Request) {
const { text } = await request.json();
const person = await chat({
adapter: openaiText("gpt-5.5"),
messages: [{ role: "user", content: `Extract the person info: ${text}` }],
outputSchema: PersonSchema,
});
return Response.json(person); // typed object → JSON
}
import { z } from "zod";

const PersonSchema = z.object({
name: z.string(),
age: z.number(),
email: z.string().email(),
});

// client
const text = "John Doe, 30, john@example.com";
const res = await fetch("/api/extract-person", {
method: "POST",
body: JSON.stringify({ text }),
});
const person = PersonSchema.parse(await res.json()); // validated + typed

이것이 가장 직접적인 단일 요청 형태입니다. 요청 하나에 객체 하나를 반환합니다. fetch와 타이핑은 직접 관리하며 훅은 관여하지 않습니다.

useChat 사용—타입이 지정된 final(선택적 partial 포함)​

훅의 편의 기능(관리되는 isLoading 상태, 스키마로 타입이 지정된 결과, 선택적인 필드별 채우기)이 필요하다면 useChat({ outputSchema })에서 final을 읽습니다. useChat은 스트림을 소비하므로 서버는 스트리밍 형태(stream: true + toServerSentEventsResponse)로 전환하지만, 클라이언트는 여전히 이를 "준비되면 객체 하나"로 처리합니다.

import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
import { z } from "zod";
import { PersonCard } from "./PersonCard";

const PersonSchema = z.object({
name: z.string(),
age: z.number(),
email: z.string().email(),
});

function PersonExtractor() {
// `final` is `z.infer<typeof PersonSchema> | null`.
// `partial` is `DeepPartial<z.infer<typeof PersonSchema>>`.
const { sendMessage, isLoading, final, partial } = useChat({
connection: fetchServerSentEvents("/api/extract-person"),
outputSchema: PersonSchema,
});

return (
<div>
<button
disabled={isLoading}
onClick={() => sendMessage("Extract: John Doe, 30, john@example.com")}
>
Extract
</button>

{/* One-shot UI: just render the validated object when it lands. */}
{final && <PersonCard person={final} />}
</div>
);
}
  • final—T | null입니다. 실행이 완료되면 채워지는 검증된 최종 객체입니다. 단일 요청 UI에서는 final을 렌더링하면 됩니다.
  • partial—DeepPartial<T>입니다. JSON이 스트리밍되는 동안 같은 객체가 필드별로 채워집니다. 완성된 결과만 필요하면 무시하고, 점진적인 폼이 필요할 때 사용합니다. Streaming UIs 가이드에서 이 패턴을 자세히 설명합니다.
  • useChat의 스키마는 클라이언트 측 TypeScript 타입 추론(partial의 점진적 파싱 포함)에 사용됩니다. 검증은 chat({ outputSchema })에 전달한 스키마를 기준으로 서버에서 계속 실행됩니다.

비스트리밍 어댑터(Anthropic, Gemini, Ollama)에서는 객체가 단일 이벤트로 도착합니다. partial은 {}로 유지되고 final이 한 번에 채워집니다. 위의 소비자 코드는 어댑터와 관계없이 동일합니다.

결과를 필드별로 채우거나 여러 턴에 걸쳐 객체 기록을 유지하고 싶다면 Streaming UIs와 Multi-Turn Chat을 사용합니다. 두 기능 모두 동일한 useChat({ outputSchema }) 인터페이스를 기반으로 합니다.

권장 사항​

  1. 설명적인 필드 이름과 설명을 사용합니다. 모델은 이를 힌트로 사용합니다.

  2. 스키마의 범위를 명확하게 유지합니다. 필요한 것만 추출합니다. 더 작은 스키마가 더 신뢰할 수 있는 결과를 만듭니다.

  3. 실제로 선택적인 필드는 선택 사항으로 표시합니다. 스키마가 값을 요구한다는 이유만으로 모델이 값을 지어내게 하지 않습니다.

  4. 제한된 값에는 열거형을 사용합니다.

    import { z } from "zod";

    const schema = z.object({
    status: z.enum(["pending", "approved", "rejected"]),
    priority: z.enum(["low", "medium", "high"]),
    });
  5. 경계 사례를 테스트합니다. 빈 입력, 모호한 입력, 추가 필드가 있는 입력을 테스트해 스키마가 예상대로 처리하는지 확인합니다.