{"slug": "stop-parsing-ai-text-build-reliable-features-with-structured-outputs", "title": "Stop Parsing AI Text: Build Reliable Features with Structured Outputs", "summary": "A developer explains how to build reliable AI features by using structured outputs with JSON Schema instead of parsing free-form text. The approach defines a data contract that models must follow, validated at runtime with tools like Zod, improving reliability for applications that consume AI responses.", "body_md": "A model returns this response:\n\n```\nPriority: high\nTeam: billing\nReason: The customer was charged twice.\n```\n\nYour application needs this:\n\n```\n{\n  \"priority\": \"high\",\n  \"team\": \"billing\",\n  \"reason\": \"The customer was charged twice.\"\n}\n```\n\nThey look equivalent to a person. To software, they are completely different interfaces.\n\nThe first response must be interpreted. The second can be validated.\n\nThat distinction matters whenever an AI response is used by code rather than displayed directly to a user. If you are extracting data, routing support requests, generating UI components, calling tools, or building an agentic workflow, free-form text is often the wrong boundary.\n\nDevelopers have been asking models to “return JSON only” for years. It is better than parsing prose, but it does not create a reliable contract.\n\nAll of these values are valid JSON:\n\n```\n{\"priority\":\"urgent\"}\n{\"priority\":\"high\",\"team\":null}\n{\"priority\":\"high\",\"team\":\"billing\",\"confidence\":\"probably\"}\n```\n\nA parser can read them, but your application may still reject them. The enum is unexpected, a required field is missing, or a number has arrived as descriptive text.\n\nJSON answers a syntax question:\n\nCan this text be parsed as JSON?\n\nA schema answers a contract question:\n\nDoes this value have the structure and constraints our application expects?\n\nJSON Schema provides a standard vocabulary for describing types, required properties, allowed values, nested objects, arrays, and other constraints. Its official documentation positions schemas as a way to improve data consistency, validation, documentation, and interoperability.\n\nThis approach is increasingly relevant to AI development. OpenAI and Google both document structured-output features based on JSON Schema, with SDK support for familiar schema tools such as Zod and Pydantic. The exact API differs, but the architectural idea is portable: define the data contract, ask the model to produce it, and validate the result before using it.\n\nImagine a support form that accepts an unstructured customer message. We want an AI model to suggest:\n\nThe result will be consumed by application code, so prose is not a suitable interface.\n\nA TypeScript type might look like this:\n\n```\ntype TicketTriage = {\n  team: \"billing\" | \"account\" | \"technical\" | \"other\";\n  priority: \"low\" | \"normal\" | \"high\";\n  summary: string;\n  needsHumanReview: boolean;\n};\n```\n\nThis type is useful inside the codebase, but TypeScript types disappear at runtime. The model response is external data, just like an HTTP request or a message from a queue. It still needs runtime validation.\n\nA common workflow begins with the prompt and asks what data the model can produce.\n\nReverse it.\n\nStart with the code that will consume the result:\n\n```\nasync function routeTicket(ticket: TicketTriage) {\n  if (ticket.needsHumanReview) {\n    return sendToReviewQueue(ticket);\n  }\n\n  return sendToTeam(ticket.team, ticket.priority, ticket.summary);\n}\n```\n\nThis function reveals the actual contract:\n\n`team`\n\nand `priority`\n\nmust use known values,`summary`\n\nmust always exist,`needsHumanReview`\n\nmust be a real boolean,The model should fit that contract. The rest of the application should not be redesigned around whatever shape the model happened to return during an early experiment.\n\nA good schema is strict enough to protect the application and small enough for people to understand.\n\nHere is a Zod schema for the example:\n\n``` js\nimport { z } from \"zod\";\n\nexport const TicketTriageSchema = z.object({\n  team: z.enum(\\[\"billing\", \"account\", \"technical\", \"other\"]),\n  priority: z.enum(\\[\"low\", \"normal\", \"high\"]),\n  summary: z.string().min(1).max(240),\n  needsHumanReview: z.boolean(),\n}).strict();\n\nexport type TicketTriage = z.infer<typeof TicketTriageSchema>;\n```\n\nThe schema does more than describe the happy path:\n\nThe equivalent JSON Schema communicates the same idea:\n\nOpen the JSON Schema example\n\n```\n{\n  \"$schema\": \"https://json-schema.org/draft/2020-12/schema\",\n  \"type\": \"object\",\n  \"additionalProperties\": false,\n  \"properties\": {\n    \"team\": {\n      \"type\": \"string\",\n      \"enum\": \\[\"billing\", \"account\", \"technical\", \"other\"]\n    },\n    \"priority\": {\n      \"type\": \"string\",\n      \"enum\": \\[\"low\", \"normal\", \"high\"]\n    },\n    \"summary\": {\n      \"type\": \"string\",\n      \"minLength\": 1,\n      \"maxLength\": 240\n    },\n    \"needsHumanReview\": {\n      \"type\": \"boolean\"\n    }\n  },\n  \"required\": \\[\n    \"team\",\n    \"priority\",\n    \"summary\",\n    \"needsHumanReview\"\n  ]\n}\n```\n\nIf the application understands three priority levels, do not let the model invent seven.\n\n```\npriority: z.enum(\\[\"low\", \"normal\", \"high\"])\n```\n\nWithout the enum, values such as `urgent`\n\n, `critical`\n\n, `medium-high`\n\n, or `as soon as possible`\n\nmay all appear reasonable. Every new label moves interpretation back into application code.\n\nAvoid a catch-all field such as:\n\n```\n{\n  \"result\": \"Billing, high priority, maybe review this\"\n}\n```\n\nIt is structured only at the outermost level. The important information is still trapped in prose.\n\nPrefer separate fields with one clear responsibility:\n\n```\n{\n  \"team\": \"billing\",\n  \"priority\": \"high\",\n  \"needsHumanReview\": true\n}\n```\n\nA schema with deeply nested alternatives, many optional properties, and overlapping meanings is difficult for humans and models alike.\n\nIf the contract becomes complicated, ask whether the workflow should be split into smaller stages. Classification, extraction, and action planning do not always belong in one response.\n\nThe schema defines **what shape is allowed**. The prompt defines **how to make the decision**.\n\nFor example:\n\n```\nClassify the support request.\n\nRouting rules:\n- Use billing for payments, invoices, refunds, and duplicate charges.\n- Use account for login, profile, and subscription-access issues.\n- Use technical for product errors and unavailable features.\n- Use other when none of the categories fit.\n\nPriority rules:\n- Use high when the customer cannot use a paid service or reports an\n  active financial problem.\n- Use normal when the issue affects use but has a workaround.\n- Use low for questions and non-blocking requests.\n\nSet needsHumanReview to true when evidence is incomplete, categories\nconflict, or the request could cause a financial or account-level action.\n```\n\nThe enum belongs in the schema. The business meaning of each enum belongs in the instructions or application policy.\n\nKeeping them separate makes both easier to maintain. You can revise decision rules without changing the response shape, or add a schema version without hiding contract changes inside a prompt.\n\nSchema conformance proves that the response has the expected shape. It does not prove that the decision is correct.\n\nThis value can be perfectly valid and still be wrong:\n\n```\n{\n  \"team\": \"technical\",\n  \"priority\": \"low\",\n  \"summary\": \"Customer reports a duplicate payment.\",\n  \"needsHumanReview\": false\n}\n```\n\nThe schema cannot know that duplicate payments belong to billing. That is a business rule.\n\nTreat validation as layers:\n\nStructured output improves the interface between the model and the application. It does not remove the rest of the application's responsibilities.\n\nEven when a provider promises schema-conforming output, validate external data before it enters your domain logic.\n\n```\nexport function parseTicketTriage(value: unknown): TicketTriage {\n  return TicketTriageSchema.parse(value);\n}\n```\n\nFor a user-facing workflow, a non-throwing result may be easier to handle:\n\n``` js\nconst parsed = TicketTriageSchema.safeParse(modelOutput);\n\nif (!parsed.success) {\n  logger.warn(\"Invalid triage response\", {\n    issues: parsed.error.issues,\n  });\n\n  return sendOriginalTicketToHumanReview();\n}\n\nreturn routeTicket(parsed.data);\n```\n\nThis boundary gives the application one trusted representation. Code after the parser can work with `TicketTriage`\n\n; code before it must treat the value as `unknown`\n\n.\n\nIt is tempting to transform almost-correct values:\n\n``` js\nconst priority = output.priority === \"urgent\"\n  ? \"high\"\n  : output.priority;\n```\n\nOne carefully chosen normalization may be harmless. A growing collection of repairs becomes an undocumented second schema.\n\nPrefer one of these responses:\n\nThe fallback should be part of the feature design, not an emergency branch added after deployment.\n\nA model will sometimes lack enough information to make a good decision. Do not force uncertainty into a confident enum.\n\nThere are several clean patterns.\n\n```\nneedsHumanReview: z.boolean()\n```\n\nThis works when a best-effort classification is still useful but the action should pause.\n\n```\nteam: z.enum(\\[\n  \"billing\",\n  \"account\",\n  \"technical\",\n  \"other\",\n  \"unknown\",\n])\n```\n\nUse this when the absence of a reliable classification is meaningful to downstream code.\n\n``` js\nconst TriageResultSchema = z.discriminatedUnion(\"status\", \\[\n  z.object({\n    status: z.literal(\"classified\"),\n    team: z.enum(\\[\"billing\", \"account\", \"technical\", \"other\"]),\n    priority: z.enum(\\[\"low\", \"normal\", \"high\"]),\n    summary: z.string().min(1).max(240),\n  }),\n  z.object({\n    status: z.literal(\"needs\\_review\"),\n    reason: z.string().min(1).max(240),\n  }),\n]);\n```\n\nThis makes success and uncertainty different states instead of mixing partially valid fields into one object.\n\nA production integration should record more than “AI request failed.” Useful signals include:\n\nBe careful with logging. Model inputs and outputs may contain personal, confidential, or regulated information. Log identifiers and structured diagnostics where possible, and apply the same retention and access rules used for other sensitive application data.\n\nA single successful response proves very little. Use a small evaluation set that resembles real input.\n\nFor ticket triage, include:\n\n``` js\nimport { describe, expect, it } from \"vitest\";\n\nconst validResult = {\n  team: \"billing\",\n  priority: \"high\",\n  summary: \"Customer reports a duplicate charge.\",\n  needsHumanReview: true,\n};\n\ndescribe(\"TicketTriageSchema\", () => {\n  it(\"accepts a valid triage result\", () => {\n    expect(TicketTriageSchema.safeParse(validResult).success).toBe(true);\n  });\n\n  it(\"rejects an invented priority\", () => {\n    const result = {\n      ...validResult,\n      priority: \"urgent\",\n    };\n\n    expect(TicketTriageSchema.safeParse(result).success).toBe(false);\n  });\n\n  it(\"rejects unexpected fields\", () => {\n    const result = {\n      ...validResult,\n      automaticRefund: true,\n    };\n\n    expect(TicketTriageSchema.safeParse(result).success).toBe(false);\n  });\n});\n```\n\nSchema tests answer whether the payload is structurally valid. Evaluation cases answer whether the classification is useful.\n\nKeep expected outcomes alongside representative inputs:\n\n``` js\nconst cases = \\[\n  {\n    input: \"I was charged twice for the same month.\",\n    expectedTeam: \"billing\",\n    expectedPriority: \"high\",\n  },\n  {\n    input: \"How do I change the name shown on my profile?\",\n    expectedTeam: \"account\",\n    expectedPriority: \"low\",\n  },\n];\n```\n\nRun these cases when you change the prompt, schema, provider, or model version. A model migration is a behavior change even when the TypeScript interface stays the same.\n\nSuggested minimum contract test suiteStructured output becomes an internal API. Treat changes accordingly.\n\nAdding a required property is a breaking change for consumers. Renaming an enum value can break routing. Changing the meaning of a field may be more dangerous than changing its type.\n\nFor persisted results or asynchronous workflows, include a version:\n\n``` js\nconst TicketTriageV1Schema = z.object({\n  schemaVersion: z.literal(\"1\"),\n  team: z.enum(\\[\"billing\", \"account\", \"technical\", \"other\"]),\n  priority: z.enum(\\[\"low\", \"normal\", \"high\"]),\n  summary: z.string().min(1).max(240),\n  needsHumanReview: z.boolean(),\n}).strict();\n```\n\nVersioning is especially useful when:\n\nNot every model response needs a schema.\n\nFree-form text is often appropriate for:\n\nStructured output becomes valuable when:\n\nA useful question is:\n\nWill a machine consume this response before a person approves it?\n\nIf the answer is yes, a schema is usually worth considering.\n\nBefore shipping a structured-output feature, check the complete boundary:\n\n`unknown`\n\nuntil validated.Prompts are instructions. Schemas are contracts.\n\nA prompt can tell a model to be concise, choose from known categories, and include every field. A schema gives the application something concrete to enforce.\n\nThe dependable pattern is straightforward:\n\n```\nDefine the consumer\n        ↓\nDesign a small schema\n        ↓\nGenerate structured output\n        ↓\nValidate at the boundary\n        ↓\nApply business rules\n        ↓\nContinue, retry, or request review\n```\n\nStructured output does not make a model infallible. It makes the integration easier to reason about.\n\nYou can observe failures, test edge cases, version the contract, and prevent malformed data from quietly entering the rest of the system. That is a much stronger foundation than another instruction to “return JSON only.”\n\nWhat kind of AI response does your application still parse from free-form text?\n\n[Explore the official JSON Schema documentation](https://json-schema.org/)\n\nIf you found this guide helpful, let's connect and discuss modern development workflows!", "url": "https://wpnews.pro/news/stop-parsing-ai-text-build-reliable-features-with-structured-outputs", "canonical_source": "https://dev.to/johnnylemonny/stop-parsing-ai-text-build-reliable-features-with-structured-outputs-8im", "published_at": "2026-08-13 14:00:00+00:00", "updated_at": "2026-08-13 14:19:47.801288+00:00", "lang": "en", "topics": ["artificial-intelligence", "developer-tools", "ai-tools"], "entities": ["OpenAI", "Google", "Zod", "JSON Schema"], "alternates": {"html": "https://wpnews.pro/news/stop-parsing-ai-text-build-reliable-features-with-structured-outputs", "markdown": "https://wpnews.pro/news/stop-parsing-ai-text-build-reliable-features-with-structured-outputs.md", "text": "https://wpnews.pro/news/stop-parsing-ai-text-build-reliable-features-with-structured-outputs.txt", "jsonld": "https://wpnews.pro/news/stop-parsing-ai-text-build-reliable-features-with-structured-outputs.jsonld"}}