Command Palette

Search for a command to run...

Hectal
PHASE 7Intermediate ~28 min· topic 3 of 7

Topic 7.3

Generating Objects with the SDK

In one line

With the AI SDK you pass output: Output.object({ schema }) to generateText or streamText and read a checked, typed result.output. When the reply is wrong the SDK throws NoObjectGeneratedError, which keeps the raw text and the finish reason. Retries for bad replies are yours to write, and the model needs a legal way to say no.

Think of it like this

The returns desk at a shop. You hand over a slip. The clerk checks every box. If a box is unreadable, they hand it back and say which one, and you can fix it once. If a question does not apply to you, the slip has a box "Not applicable", so you never have to invent an answer. The SDK is the clerk. Your schema is the slip. Your code decides what to do when the slip comes back.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Output.object
The AI SDK option that asks for an object of a given schema and returns it parsed and checked as result.output.
NoObjectGeneratedError
Thrown when the model's reply could not become a valid object. Has text, finishReason, usage and cause.
NoOutputGeneratedError
Thrown when you read result.output and the model produced nothing, such as an empty reply after a content filter.
APICallError
Thrown when the request to the provider failed. Has statusCode and isRetryable.
maxRetries
How many times the SDK retries a failed API request (default 2). Does not apply to replies that fail your schema.
cause
The error behind an error. Here it leads to the zod issues that explain what was wrong.
Discriminated union
A set of object shapes told apart by one field, such as kind. Lets a reply be a card, a text or a refusal.
Response format
The part of the request that tells the provider the shape the reply must have.
Content filter
A provider safety step that can stop a reply. It may show up as finishReason: "content-filter" with no text.

Step by step

01The route on a real server

In a real app this is one file on your server. The model and its key are the only things that differ from the live examples, which use a scripted stand-in model.

app/api/order/route.ts (real app)whole filets
import { generateText, Output, NoObjectGeneratedError } from "ai";
import { OrderCard } from "@/lib/schemas";
import { yourProvider } from "@/lib/model"; // from your provider package, with its key in an environment variable

export async function POST(req: Request) {
  const { question } = await req.json();
  try {
    const { output } = await generateText({
      model: yourProvider("provider/model-name"),
      prompt: question,
      maxRetries: 2, // for 429 and 500 from the provider
      output: Output.object({ schema: OrderCard }),
    });
    return Response.json(output);
  } catch (e) {
    if (NoObjectGeneratedError.isInstance(e)) console.error("bad reply", e.finishReason, e.text);
    return Response.json({ error: "Mitra could not read its own answer." }, { status: 502 });
  }
}

02Five replies, five outcomes

The first live example runs the same call five times. Read model calls on each line. Not JSON, wrong shape and cut off each made one call: nothing retried them. The 429 made two: the SDK retried the API failure by itself and the second attempt worked.

Then streamText with the same option: await result.output gives the object, or rejects with the same error class.

03Three errors, three screens

Bad reply, nothing generated and request failed are different problems. A good UI treats them differently.

Three errors, three screensdiagram
Rendering diagram…

04A way out, and one careful retry

The second example does two things. First, a kind union: the model answers order or cannot_help, and both parse. Second, a retry you wrote: when the schema fails, it reads zod's issues from e.cause.cause.issues, adds "Your last reply was rejected: days: Invalid input: expected number, received string" to the prompt, and asks once more. The scripted model "learns" from that message, a real one often (not always) does.

05One function for every failure

The third example is the mapping a real app needs: every error class becomes one sentence for the user. A content filter and a cut-off reply are different sentences. A 429 says the service is busy; a 500 says it could not be reached.

Notice the content filter case: the call returns (its finishReason is content-filter and text is empty) and the error only appears when you read result.output.

Try it yourself

  1. 1

    A retry that never gets better

    In the second example, change the stand-in's second answer so it is also bad: return '{"orderId":"ORD-1042","days":"two"}' every time. Run it.

    With attemptsLeft = 1 the loop asks twice and then throws NoObjectGeneratedError out of generateOrder. A retry loop must have a cap, and the caller must still handle the final failure.

  2. 2

    Remove the way out

    In the second example's part 1, replace Answer with a schema that has only the order shape: z.object({ kind: z.literal("order"), orderId: z.string(), status: z.enum(["shipped", "processing", "cancelled"]) }). Then run the declined line.

    It throws a NoObjectGeneratedError (the reply does not match the schema). A model that wants to decline has nowhere to go, so with a real model you would get either an invented order or a failure.

  3. 3

    Raise maxRetries

    In the first example, change the reply of wrong shape to a function that returns a bad reply on call 1 and a good one on call 2, and set maxRetries: 5.

    It still stops after one call. Only provider (API) failures are retried by the SDK.

Code & diagrams

One call, five outcomes, and streamText

The model is a scripted stand-in. In a real app the only change is the model line. Look at the model calls column: only the 429 caused a second call. The stand-in sends a Retry-After of 0 seconds so the retry is instant; a real provider sets its own wait, and without that header the SDK waits longer each time.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

valid: ok { orderId: 'ORD-1042', status: 'shipped', days: 2 } | model calls: 1
not JSON: NoObjectGeneratedError | model calls: 1 | finishReason: stop | text: "I cannot say."
wrong shape: NoObjectGeneratedError | model calls: 1 | finishReason: stop | text: "{\"orderId\":\"ORD-1042\"}"
cut off: NoObjectGeneratedError | model calls: 1 | finishReason: length | text: "{\"orderId\":\"ORD-1042\",\"status\":\"shi"
429 then valid: ok { orderId: 'ORD-1042', status: 'shipped', days: 2 } | model calls: 2
streamText output: { orderId: 'ORD-1042', status: 'shipped', days: 2 }
streamText bad output rejects: AI_NoObjectGeneratedError | No object generated: response did not match schema.
Let the model say no, and ask again once

The retry is your code, capped at one extra attempt. The error text went into the prompt, so the second call sees what was wrong. A real model usually, but not always, fixes it.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

normal: { kind: 'order', orderId: 'ORD-1042', status: 'shipped' }
declined: { kind: 'cannot_help', reason: 'I can only talk about orders.' }
  model call 1 sees: "Status of ORD-1042?"
  rejected: days: Invalid input: expected number, received string
  model call 2 sees: "Status of ORD-1042?\nYour last reply was "
final: { orderId: 'ORD-1042', days: 2 }
total model calls: 2
Nothing generated, cut off, 429 and 500: one sentence each

Watch the content filter: the first line shows the call returned normally with empty text. The throw only happens on the next line, when the code reads result.output.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

valid: finishReason stop | text "{\"orderId\":\"ORD-1042\",\"days\":2}"
  output: { orderId: 'ORD-1042', days: 2 }
content filter: finishReason content-filter | text ""
content filter: threw AI_NoOutputGeneratedError -> Mitra could not answer that one. Try asking in a different way.
cut off: threw AI_NoObjectGeneratedError -> Mitra's answer was too long and got cut off.
rate limited: threw AI_APICallError 429 -> Mitra is busy right now. Please try again in a moment.
provider down: threw AI_APICallError 500 -> Mitra could not be reached. Please try again.

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Read result.output without a catch

In the third example, delete the try {, the } catch (e) { … } block and the explain calls, so run just calls generateText and logs. Keep the three runs valid, content filter and cut off.

terminal
$ Console
── what you'll see ──
valid: finishReason stop | text "{\"orderId\":\"ORD-1042\",\"days\":2}"
output: { orderId: 'ORD-1042', days: 2 }
content filter: finishReason content-filter | text ""
Uncaught AI_NoOutputGeneratedError: No output generated.

Myth vs fact

Myth

maxRetries makes the SDK ask again when the JSON is wrong.

Fact

It retries failed requests (429, 500). A reply that fails your schema is a successful request, so it throws once, after one model call.

Myth

If the call did not throw, result.output is fine.

Fact

With a content filter the call returns, and reading result.output throws. Read it inside your try.

Myth

A strict schema stops the model from making things up.

Fact

It forces the shape, not the facts. If it has no legal way to say "I don't know", it may fill a box with something invented.

Myth

generateObject is the way to get an object.

Fact

In AI SDK 7 it is deprecated. Use generateText or streamText with output: Output.object({ schema }).

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Log e.text, e.finishReason and e.usage for every NoObjectGeneratedError, with your own request id. A rate of bad replies that goes up after a model change is the first sign that the new model follows your schema less well.

  • ▸

    Retries multiply cost. One retry on every bad reply doubles the spend for those requests, and a schema that fails 10% of the time quietly adds about 10% to the bill. Measure the failure rate before adding a retry loop, and cap it at one or two.

  • ▸

    Two kinds of problems need different handling in monitoring: provider errors (429/500, your SLA with the vendor) and schema failures (your prompt and schema quality). Count them separately.

  • ▸

    The client validating again is not duplication. The server's check protects the server's trust in the model; the client's check protects the page from a bug, an old cached response or a broken deploy.

Remember this

  1. 1

    The problem this solves: Topics 7.1 and 7.2 did the parsing and checking by hand. The SDK can do both in one call and give you a typed result. But it also has to tell you *which* way a reply failed, because a cut-off reply, a wrongly shaped reply and a provider outage need three different screens.

  2. 2

    The call. generateText({ model, prompt, output: Output.object({ schema }) }) waits for the whole reply and returns a result whose output is already parsed and checked. streamText takes the same output option and gives await result.output (a promise) once the stream is complete (Topic 7.4 streams the partial pieces). Output is imported from ai. The older generateObject and streamObject functions still exist in AI SDK 7, but are marked deprecated in favour of this option.

  3. 3

    What the SDK does inside. It converts your zod schema to JSON Schema and sends it as the response format (Topic 7.2 printed it). It lets the model finish. Then it parses the text as JSON and validates it against your schema. If both work, you get the object. If not, it throws. No hidden repair step runs in between. So everything in Topic 7.1 can still happen, and now you can see it as a typed error.

  4. 4

    Three different errors. (1) NoObjectGeneratedError (its name is AI_NoObjectGeneratedError): the model answered but the text did not become a valid object. It carries text (the raw reply), finishReason (length means cut off), usage, and cause: a JSON parse error for text that is not JSON, or a type validation error whose own cause holds zod's issues. (2) NoOutputGeneratedError: the model finished with nothing usable, for example finishReason: "content-filter" and an empty reply. In that case the call itself returns, and reading result.output is what throws. (3) APICallError: the request itself failed, with statusCode (such as 429 or 500) and isRetryable. Check with .isInstance(e) on each class.

  5. 5

    Two kinds of retry, not the same thing. maxRetries (default 2) retries *API* failures such as a 429 or 500, waiting longer each time. The example shows a 429 followed by a success costing two model calls. It does not retry a reply that fails the schema: maxRetries: 3 and a wrong reply still made one call. If you want to ask again after a bad reply, you write that loop: catch NoObjectGeneratedError, add what was wrong to the prompt, try once or twice at most. Every retry costs tokens and makes the user wait longer, and a length failure will usually fail again unless you change the schema or the limit.

  6. 6

    Let the model say no. A model may decline, or simply not know. If your schema has no place for "I can't help", it will often invent something that fits the boxes, which is worse than refusing. Use a discriminated union: an object with a kind field that selects one of several shapes, such as { kind: "order", … } or { kind: "cannot_help", reason }. Then declining is a normal valid reply that you show as a normal message. A provider-side refusal may still arrive as an empty reply with a content filter, so handle that one too.

  7. 7

    Where this runs. The call needs your API key, so it lives on the server (app/api/…/route.ts, here a serve route). The route returns the validated object as JSON, and the client validates it again with the *same* schema before rendering, because the browser must not trust the network any more than the server trusts the model. The user never sees an error class: map each one to a plain sentence in one function, as the third example does.

Explain it without notes

01

How do you ask the AI SDK for a typed object, and what does it do between the request and result.output?

02

Name the three error classes and what each one tells the UI.

03

Does maxRetries retry a reply that fails the schema? What should you do instead?

04

How do you let a model decline without breaking your schema?

05

Why should a failed structured reply produce more than one UI message?

Practice

01

Write a route-style function getCard(question) that calls generateText with Output.object, and returns { ok: true, card } or { ok: false, message } using a mapping like explain above. It must never throw.

02

Add a cannot_help branch to a card schema and write the client code that shows the reason in a gray note instead of a card.

Trade-offs

  • ↔

    Retrying a bad reply once raises the success rate but doubles the cost and the wait for those requests. One quick retry with the issues in the prompt is a common balance. More than that mostly spends money.

  • ↔

    A union with a decline branch makes the schema bigger and the UI handle one more case, in return for honest "I can't" answers instead of invented ones.

Done when you can

  • I can get a typed object with generateText or streamText and Output.object.

  • I can name the three error classes and what each one means for the screen.

  • I can explain what maxRetries does and does not retry.

  • I can write a bounded retry that feeds the zod issues back into the prompt.

  • I can design a schema where the model can decline.