{"course":{"slug":"building-apps-with-ai","title":"Building Apps with AI","url":"https://addaly.com/learn/building-apps-with-ai","level":"beginner","track":"build"},"lesson":{"slug":"structured-answers-you-can-render","title":"Structured answers you can render","url":"https://addaly.com/learn/building-apps-with-ai/structured-answers-you-can-render","position":62,"of":87,"minutes":8,"answer":"Ask for JSON in a declared shape, validate it in code before anything reads it, retry once with the error, and have a plan for the second failure.","keyIdea":"Ask for JSON in a declared shape, validate it in code before anything reads it, retry once with the error, and have a plan for the second failure, because the model will invent a fifth value for a four-value field."},"body":{"format":"markdown","text":"## Text is not enough\n\nA summary can be shown as a paragraph. A category, a list of action items with owners and dates, a score out of ten, a set of tags: these have to be read by code, and code cannot read prose. The model has to answer in a shape.\n\nThe generated version asks \"respond in JSON\", runs `JSON.parse` on whatever comes back, and works until the day the model wraps the JSON in a markdown fence, adds a sentence before it, or returns a field the code did not expect. Each of those is a crash in a route that had been fine for weeks.\n\n## Declare the shape\n\nEvery major provider now has a way to say what shape you want and have the model held to it: JSON mode, a response schema, or a tool definition whose arguments are your structure. The exact names differ; the idea is the same and the compatibility layers pass it through:\n\n```typescript\nresponse_format: {\n  type: \"json_schema\",\n  json_schema: {\n    name: \"expense\",\n    schema: {\n      type: \"object\",\n      properties: {\n        category: { type: \"string\", enum: [\"travel\", \"food\", \"equipment\", \"other\"] },\n        amount: { type: \"number\" },\n        confidence: { type: \"number\", minimum: 0, maximum: 1 },\n      },\n      required: [\"category\", \"amount\", \"confidence\"],\n      additionalProperties: false,\n    },\n  },\n}\n```\n\nWith a schema, the model's output is constrained during generation on providers that support it, and you get valid JSON in that shape nearly every time. Nearly.\n\n## Validate anyway\n\nThe schema is a request to the provider. Your code's assumptions are your responsibility. Parse and validate before anything reads the result:\n\n```typescript\nimport { z } from \"zod\";\n\nconst Expense = z.object({\n  category: z.enum([\"travel\", \"food\", \"equipment\", \"other\"]),\n  amount: z.number().nonnegative(),\n  confidence: z.number().min(0).max(1),\n});\n\nconst raw = data.choices[0].message.content ?? \"\";\nconst parsed = Expense.safeParse(JSON.parse(stripFences(raw)));\nif (!parsed.success) { /* the retry below */ }\n```\n\n`stripFences` removes a leading fence line (three backticks and `json`) and a trailing fence if present; models add them even when told not to, and this is cheaper than arguing. `safeParse` returns a result rather than throwing, and the failure carries the exact reason (\"category: expected one of travel | food | equipment | other, received Miscellaneous/Other\") which is the one thing you want in the log and the one thing the retry needs.\n\n## Retry once, with the error\n\nOn a validation failure, send one more request with the original prompt plus the error message: \"Your previous answer failed validation: <error>. Reply again with only the JSON.\" That fixes most cases, because the model is good at correcting a specific mistake it can see.\n\nOnce. A loop that retries until valid can run for minutes on a model that has decided the category is \"Miscellaneous/Other\", and each attempt costs tokens.\n\n## The second failure needs a plan\n\nAfter the retry, the answer is still invalid. What now depends on the feature, and deciding it in advance is the design work:\n\n- A category can fall back to `other` and be flagged for a person to look at.\n- A score can be recorded as missing rather than as zero, because zero is a number and the page will average it.\n- A list of action items can be shown as the raw text with a note that it could not be structured.\n\nWhat never happens is silently substituting a default and moving on as if the model had said it. A missing value is a fact about this input; a fabricated one is a lie the rest of the app will repeat.\n\n## Numbers, dates and the enum it invents\n\nThree shapes cause most of the residual failures. Numbers arrive as strings (`\"amount\": \"42.50\"`), and `z.coerce.number()` handles it. Dates arrive in whatever format the input used, and the only safe request is ISO 8601 with an explicit instruction and a validation that parses it. And enums grow: told four categories, the model will eventually produce a fifth that is a plausible blend of two. The enum in the validator is what catches it; the prompt is what makes it rare.\n\n## What structured output cannot do\n\nIt cannot make the content correct. A well-formed `{\"amount\": 4250}` for a receipt that says 42.50 passes every check above. The shape is guaranteed; the values are still the model's reading of the input, and the lesson on whether the feature is good is where that gets measured."},"check":{"question":"An expense categoriser asks the model for JSON with a category from a list of four. It works for weeks, then a page crashes. The log shows the model returned {\"category\": \"Miscellaneous/Other\"}. What went wrong in the code?","options":[{"text":"The model's answer was parsed and handed to the page without being validated against the four allowed values, so an unexpected value reached code that assumed it could not.","correct":true},{"text":"The prompt should have listed the four categories more clearly, with an example of each, so the model would not have invented a fifth one for an expense that fitted none of them.","correct":false,"tempting":"Clarity lowers the rate and the model still occasionally produces a value outside the list; the code must be ready for it regardless of the prompt."},{"text":"The temperature was too high, which caused the model to be creative with the category name.","correct":false,"tempting":"Lower temperature makes it rarer and does not make it impossible; a value the code does not check for will eventually arrive."},{"text":"JSON mode was not turned on, so the model was free to return arbitrary text.","correct":false,"tempting":"The output is valid JSON; the problem is a valid document with an invalid value, which JSON mode cannot prevent."}]},"prev":{"slug":"user-text-inside-a-prompt-is-untrusted","title":"User text inside a prompt is untrusted","url":"https://addaly.com/learn/building-apps-with-ai/user-text-inside-a-prompt-is-untrusted"},"next":{"slug":"capping-what-one-user-can-cost","title":"Capping what one user can cost you","url":"https://addaly.com/learn/building-apps-with-ai/capping-what-one-user-can-cost"},"authorship":"Founder and series editor: Dr Mustafa Mun. Written by AI models to a written standard. Exam keys checked blind by a second model: 3,665 of 3,666 agreed. Academic review invited.","review":{"status":"Draft, open for academic review"},"cite":"Addaly (2026) 'Structured answers you can render', in Building Apps with AI. Edited by M. Mun. Addaly. Available at: https://addaly.com/learn/building-apps-with-ai/structured-answers-you-can-render (Accessed: 13 October 2026).","licence":{"name":"CC BY-NC-SA 4.0","url":"https://creativecommons.org/licenses/by-nc-sa/4.0/"},"reader":{"schema":1,"title":"Structured answers you can render","courseTitle":"Building Apps with AI","module":{"index":7,"slug":"a-model-inside-your-app","title":"Putting a model inside your own app"},"answer":null,"keyIdea":"Ask for JSON in a declared shape, validate it in code before anything reads it, retry once with the error, and have a plan for the second failure, because the model will invent a fifth value for a four-value field.","hero":{"kind":"plate","image":null,"alt":null,"caption":null},"blocks":[{"t":"h2","id":"text-is-not-enough","runs":[{"text":"Text is not enough"}]},{"t":"p","runs":[{"text":"A summary can be shown as a paragraph. A category, a list of action items with owners and dates, a score out of ten, a set of tags: these have to be read by code, and code cannot read prose. The model has to answer in a shape."}]},{"t":"p","runs":[{"text":"The generated version asks “respond in JSON”, runs "},{"text":"JSON.parse","code":true},{"text":" on whatever comes back, and works until the day the model wraps the JSON in a markdown fence, adds a sentence before it, or returns a field the code did not expect. Each of those is a crash in a route that had been fine for weeks."}]},{"t":"h2","id":"declare-the-shape","runs":[{"text":"Declare the shape"}]},{"t":"p","runs":[{"text":"Every major provider now has a way to say what shape you want and have the model held to it: JSON mode, a response schema, or a tool definition whose arguments are your structure. The exact names differ; the idea is the same and the compatibility layers pass it through:"}]},{"t":"code","lang":"typescript","label":"TypeScript","text":"response_format: {\n  type: \"json_schema\",\n  json_schema: {\n    name: \"expense\",\n    schema: {\n      type: \"object\",\n      properties: {\n        category: { type: \"string\", enum: [\"travel\", \"food\", \"equipment\", \"other\"] },\n        amount: { type: \"number\" },\n        confidence: { type: \"number\", minimum: 0, maximum: 1 },\n      },\n      required: [\"category\", \"amount\", \"confidence\"],\n      additionalProperties: false,\n    },\n  },\n}"},{"t":"p","runs":[{"text":"With a schema, the model’s output is constrained during generation on providers that support it, and you get valid JSON in that shape nearly every time. Nearly."}]},{"t":"h2","id":"validate-anyway","runs":[{"text":"Validate anyway"}]},{"t":"p","runs":[{"text":"The schema is a request to the provider. Your code’s assumptions are your responsibility. Parse and validate before anything reads the result:"}]},{"t":"code","lang":"typescript","label":"TypeScript","text":"import { z } from \"zod\";\n\nconst Expense = z.object({\n  category: z.enum([\"travel\", \"food\", \"equipment\", \"other\"]),\n  amount: z.number().nonnegative(),\n  confidence: z.number().min(0).max(1),\n});\n\nconst raw = data.choices[0].message.content ?? \"\";\nconst parsed = Expense.safeParse(JSON.parse(stripFences(raw)));\nif (!parsed.success) { /* the retry below */ }"},{"t":"p","runs":[{"text":"stripFences","code":true},{"text":" removes a leading fence line (three backticks and "},{"text":"json","code":true},{"text":") and a trailing fence if present; models add them even when told not to, and this is cheaper than arguing. "},{"text":"safeParse","code":true},{"text":" returns a result rather than throwing, and the failure carries the exact reason (“category: expected one of travel | food | equipment | other, received Miscellaneous/Other”) which is the one thing you want in the log and the one thing the retry needs."}]},{"t":"h2","id":"retry-once-with-the-error","runs":[{"text":"Retry once, with the error"}]},{"t":"p","runs":[{"text":"On a validation failure, send one more request with the original prompt plus the error message: “Your previous answer failed validation: <error>. Reply again with only the JSON.” That fixes most cases, because the model is good at correcting a specific mistake it can see."}]},{"t":"p","runs":[{"text":"Once. A loop that retries until valid can run for minutes on a model that has decided the category is “Miscellaneous/Other”, and each attempt costs tokens."}]},{"t":"h2","id":"the-second-failure-needs-a-plan","runs":[{"text":"The second failure needs a plan"}]},{"t":"p","runs":[{"text":"After the retry, the answer is still invalid. What now depends on the feature, and deciding it in advance is the design work:"}]},{"t":"ul","items":[[{"text":"A category can fall back to "},{"text":"other","code":true},{"text":" and be flagged for a person to look at."}],[{"text":"A score can be recorded as missing rather than as zero, because zero is a number and the page will average it."}],[{"text":"A list of action items can be shown as the raw text with a note that it could not be structured."}]]},{"t":"p","runs":[{"text":"What never happens is silently substituting a default and moving on as if the model had said it. A missing value is a fact about this input; a fabricated one is a lie the rest of the app will repeat."}]},{"t":"h2","id":"numbers-dates-and-the-enum-it-invents","runs":[{"text":"Numbers, dates and the enum it invents"}]},{"t":"p","runs":[{"text":"Three shapes cause most of the residual failures. Numbers arrive as strings ("},{"text":"\"amount\": \"42.50\"","code":true},{"text":"), and "},{"text":"z.coerce.number()","code":true},{"text":" handles it. Dates arrive in whatever format the input used, and the only safe request is ISO 8601 with an explicit instruction and a validation that parses it. And enums grow: told four categories, the model will eventually produce a fifth that is a plausible blend of two. The enum in the validator is what catches it; the prompt is what makes it rare."}]},{"t":"h2","id":"what-structured-output-cannot-do","runs":[{"text":"What structured output cannot do"}]},{"t":"p","runs":[{"text":"It cannot make the content correct. A well-formed "},{"text":"{\"amount\": 4250}","code":true},{"text":" for a receipt that says 42.50 passes every check above. The shape is guaranteed; the values are still the model’s reading of the input, and the lesson on whether the feature is good is where that gets measured."}]}],"check":{"question":[{"text":"An expense categoriser asks the model for JSON with a category from a list of four. It works for weeks, then a page crashes. The log shows the model returned {“category”: “Miscellaneous/Other”}. What went wrong in the code?"}],"options":[{"text":[{"text":"The model’s answer was parsed and handed to the page without being validated against the four allowed values, so an unexpected value reached code that assumed it could not."}],"correct":true,"tempting":null},{"text":[{"text":"The prompt should have listed the four categories more clearly, with an example of each, so the model would not have invented a fifth one for an expense that fitted none of them."}],"correct":false,"tempting":[{"text":"Clarity lowers the rate and the model still occasionally produces a value outside the list; the code must be ready for it regardless of the prompt."}]},{"text":[{"text":"The temperature was too high, which caused the model to be creative with the category name."}],"correct":false,"tempting":[{"text":"Lower temperature makes it rarer and does not make it impossible; a value the code does not check for will eventually arrive."}]},{"text":[{"text":"JSON mode was not turned on, so the model was free to return arbitrary text."}],"correct":false,"tempting":[{"text":"The output is valid JSON; the problem is a valid document with an invalid value, which JSON mode cannot prevent."}]}],"order":[3,1,2,0],"why":null},"prev":{"slug":"user-text-inside-a-prompt-is-untrusted","title":"User text inside a prompt is untrusted","minutes":9},"next":{"slug":"capping-what-one-user-can-cost","title":"Capping what one user can cost you","minutes":8}}}