단일 추출
구조화되지 않은 입력(텍스트 단락, 자유 형식의 사용자 프롬프트, 이메일 본문)이 있고 타입이 지정된 객체 하나만 반환받고 싶을 수 있습니다. 스트리밍도, 기록도, 에이전트 루프도 없습니다. 프롬프트 하나를 입력하고 검증된 객체 하나를 출력합니다.
이 가이드를 마치면 완전히 타입이 지정된 결과를 반환하는 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: false | Promise<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 })인터페이스를 기반으로 합니다.
권장 사항
-
설명적인 필드 이름과 설명을 사용합니다. 모델은 이를 힌트로 사용합니다.
-
스키마의 범위를 명확하게 유지합니다. 필요한 것만 추출합니다. 더 작은 스키마가 더 신뢰할 수 있는 결과를 만듭니다.
-
실제로 선택적인 필드는 선택 사항으로 표시합니다. 스키마가 값을 요구한다는 이유만으로 모델이 값을 지어내게 하지 않습니다.
-
제한된 값에는 열거형을 사용합니다.
import { z } from "zod";
const schema = z.object({
status: z.enum(["pending", "approved", "rejected"]),
priority: z.enum(["low", "medium", "high"]),
}); -
경계 사례를 테스트합니다. 빈 입력, 모호한 입력, 추가 필드가 있는 입력을 테스트해 스키마가 예상대로 처리하는지 확인합니다.