Structured output
What you will achieve
Section titled “What you will achieve”Pass a JSON Schema to complete(), get back a typed parsed object — no
JSON.parse, no markdown-fence stripping, no per-provider workaround.
When and why you need this
Section titled “When and why you need this”LLMs produce text. When you need structured data (an address, a product list, a sentiment score) you must constrain the model to emit valid JSON and then parse it. Without constraints the model may emit prose, wrap JSON in a code block, or drop required fields.
Providers have different mechanisms for this constraint:
- OpenAI supports
response_format: { type: 'json_schema', json_schema: { ... } }. - Anthropic has no native JSON-mode; the standard workaround is a forced tool call where the schema is wrapped in a tool definition and the model’s tool-call arguments are the structured output.
- Google supports
response_mime_type: 'application/json'withresponse_schema.
Maintaining three code paths — one per provider — in an application that might switch providers is expensive and error-prone.
Step by step
Section titled “Step by step”Step 1 — Define your schema inline
Section titled “Step 1 — Define your schema inline”import { complete } from '@combycode/llm-sdk';
const { parsed } = await complete<{ city: string; tempC: number }>({ model: process.env.LLM_MODEL!, apiKey: process.env.LLM_API_KEY, prompt: 'Extract the city and temperature in Celsius. Text: "Paris is 20 degrees C."', structured: { schema: { type: 'object', properties: { city: { type: 'string' }, tempC: { type: 'number' }, }, required: ['city', 'tempC'], additionalProperties: false, }, }, maxTokens: 64,});
console.log(parsed.city); // 'Paris'console.log(parsed.tempC); // 20The generic <T> on complete<T>() types parsed as T. TypeScript infers all
field types from your generic — no extra cast.
Step 2 — Understand what the provider receives
Section titled “Step 2 — Understand what the provider receives”Under the hood the adapter inspects provider capability:
- OpenAI receives
response_format: { type: 'json_schema', json_schema: { name, strict, schema } }. - Anthropic receives an extra tool named
structured_outputwith your schema as parameters;tool_choiceis forced to{ type: 'tool', name: 'structured_output' }. The tool call arguments are extracted transparently. - Google receives
response_mime_type: 'application/json'andresponse_schema.
This translation is automatic. You write one schema, the adapter does the right thing.
Step 3 — Classify sentiment with an enum schema
Section titled “Step 3 — Classify sentiment with an enum schema”Enum schemas work the same way — just add an enum constraint on the property:
const { parsed } = await complete<{ sentiment: 'positive' | 'negative' | 'neutral' }>({ model: process.env.LLM_MODEL!, apiKey: process.env.LLM_API_KEY, prompt: 'Classify the sentiment: "The product is surprisingly good."', structured: { schema: { type: 'object', properties: { sentiment: { type: 'string', enum: ['positive', 'negative', 'neutral'] }, }, required: ['sentiment'], additionalProperties: false, }, }, maxTokens: 32,});
console.log(parsed.sentiment); // 'positive'The structured option on complete() accepts { schema, name? } only. There is no
strict field here — strict enforcement is an adapter-level detail handled
automatically per provider (OpenAI: json_schema.strict: true; Anthropic: forced tool
call; Google: response_mime_type).
Step 4 — Nested and array schemas
Section titled “Step 4 — Nested and array schemas”Schemas are plain JSON Schema objects. Nested objects and arrays work as expected:
type Invoice = { vendor: string; total: number; items: Array<{ description: string; amount: number }>;};
const { parsed } = await complete<Invoice>({ model: process.env.LLM_MODEL!, apiKey: process.env.LLM_API_KEY, prompt: 'Extract invoice data from: "Acme Corp. Items: Widget $12.50, Bolt $0.99. Total $13.49"', structured: { schema: { type: 'object', properties: { vendor: { type: 'string' }, total: { type: 'number' }, items: { type: 'array', items: { type: 'object', properties: { description: { type: 'string' }, amount: { type: 'number' }, }, required: ['description', 'amount'], }, }, }, required: ['vendor', 'total', 'items'], }, }, maxTokens: 256,});Step 5 — Handle parse failures
Section titled “Step 5 — Handle parse failures”If the model emits invalid JSON (network truncation, unusual model behaviour),
complete() throws a SyntaxError from JSON.parse — it does not return
undefined. Wrap in a try/catch to recover:
let result: { parsed?: MyType; text: string } | undefined;try { result = await complete<MyType>({ model: process.env.LLM_MODEL!, apiKey: process.env.LLM_API_KEY, prompt, structured: { schema }, maxTokens: 256, });} catch (err) { if (err instanceof SyntaxError) { console.warn('parse failed, retrying...'); // retry or fall back } else { throw err; }}parsed is undefined only when no schema was passed to complete(), not on a
parse error. When a schema is passed and the model output is valid JSON, parsed is
always present.
Your options
Section titled “Your options”The structured option takes two fields:
| Field | Type | Default | Notes |
|---|---|---|---|
schema | Record<string, unknown> | required | A JSON Schema object. Must be a valid JSON Schema — the SDK does not validate the schema itself, but invalid schemas will cause provider errors. |
name | string | 'structured_output' | The tool name used on Anthropic (where structured output is implemented via a forced tool call). Also used as json_schema.name on OpenAI. Use a descriptive name when debugging Anthropic tool-call logs. |
complete<T>() vs LLMClient.structuredComplete<T>()
complete<T>() with a structured option is the recommended path. It returns
{ text, parsed?, response } where parsed is typed as T when a schema is passed.
LLMClient.structuredComplete<T>(input, schema, options) is a lower-level convenience
on the LLMClient class. It calls complete() internally and throws if parsing fails
(instead of returning undefined). Use it when you want the parse failure to propagate
as an exception rather than checking parsed !== undefined.
Schema compatibility across providers
Anthropic’s forced-tool workaround accepts any valid JSON Schema. OpenAI’s strict mode
rejects schemas using anyOf, oneOf, $ref, optional properties (must all be in
required), or null types without explicit union. If you target multiple providers,
design schemas conservatively: flat objects, all fields required, no anyOf.
Compare the SDKs
Section titled “Compare the SDKs”Scenario 11 (raw structured JSON):
Scenario 12 (typed structured parse):
The structural difference: official SDKs require you to use three distinct mechanisms.
With OpenAI you set response_format and call JSON.parse() or use zodResponseFormat.
With Anthropic you define a tool, force tool_choice, and extract tool_use.input from
the response content. With Google you set responseMimeType and responseSchema and
call JSON.parse() on candidates[0].content.parts[0].text. ORXA reduces this to one
structured field; the provider selection, schema translation, and JSON parsing are all
done internally.
Gotchas and next steps
Section titled “Gotchas and next steps”Parse failure throws, not returns undefined. When the model emits malformed JSON,
complete() throws a SyntaxError. parsed is only undefined when no schema was
passed at all. Always wrap structured calls in try/catch if you want to retry on bad
output.
Model ignoring the schema. If the throw message shows JSON wrapped in a markdown
code block, the model ignored your structured-output constraint. This happens more with
smaller or older models. Try being more explicit in your prompt:
'Respond with JSON only, matching this exact schema: ...'.
Do not use structured with stream(). The streaming path does not support
structured output — the model must emit the complete JSON in a single block. Use
complete() for all structured-output calls.
Next steps:
- Tool call — let the model call functions rather than returning JSON
- Reasoning — combine reasoning with structured extraction
- Quickstart — the non-structured baseline