A model returns this response:
Priority: high
Team: billing
Reason: The customer was charged twice.
Your application needs this:
{
"priority": "high",
"team": "billing",
"reason": "The customer was charged twice."
}
They look equivalent to a person. To software, they are completely different interfaces.
The first response must be interpreted. The second can be validated.
That 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.
Table of contents
- Why valid JSON is not enough
- Start with the consumer
- Design a small schema
- Keep instructions and structure separate
- Validate at the boundary
- Handle uncertainty explicitly
- Test the contract
- Know when not to use structured output
- Use the production checklist
Why valid JSON is not enough
Developers have been asking models to βreturn JSON onlyβ for years. It is better than parsing prose, but it does not create a reliable contract.
All of these values are valid JSON:
{"priority":"urgent"}
{"priority":"high","team":null}
{"priority":"high","team":"billing","confidence":"probably"}
A 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.
JSON answers a syntax question:
Can this text be parsed as JSON?
A schema answers a contract question:
Does this value have the structure and constraints our application expects?
JSON 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.
This 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.
A running example: support ticket triage
Imagine a support form that accepts an unstructured customer message. We want an AI model to suggest:
- the responsible team,
- the priority,
- a short summary,
- whether a human must review the decision.
The result will be consumed by application code, so prose is not a suitable interface.
A TypeScript type might look like this:
type TicketTriage = {
team: "billing" | "account" | "technical" | "other";
priority: "low" | "normal" | "high";
summary: string;
needsHumanReview: boolean;
};
This 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.
Start with the consumer
A common workflow begins with the prompt and asks what data the model can produce.
Reverse it.
Start with the code that will consume the result:
async function routeTicket(ticket: TicketTriage) {
if (ticket.needsHumanReview) {
return sendToReviewQueue(ticket);
}
return sendToTeam(ticket.team, ticket.priority, ticket.summary);
}
This function reveals the actual contract:
-
teamandprioritymust use known values, -
summarymust always exist, -
needsHumanReviewmust be a real boolean, - unexpected fields are unnecessary,
- uncertain cases need a safe path.
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.
Design a small schema
A good schema is strict enough to protect the application and small enough for people to understand.
Here is a Zod schema for the example:
import { z } from "zod";
export const TicketTriageSchema = z.object({
team: z.enum(\["billing", "account", "technical", "other"]),
priority: z.enum(\["low", "normal", "high"]),
summary: z.string().min(1).max(240),
needsHumanReview: z.boolean(),
}).strict();
export type TicketTriage = z.infer<typeof TicketTriageSchema>;
The schema does more than describe the happy path:
- enums prevent invented categories,
- length limits keep the summary usable,
- required fields eliminate ambiguous absence,
- strict object validation rejects unexpected properties,
- the inferred type keeps runtime and compile-time contracts aligned.
The equivalent JSON Schema communicates the same idea:
Open the JSON Schema example
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"properties": {
"team": {
"type": "string",
"enum": \["billing", "account", "technical", "other"]
},
"priority": {
"type": "string",
"enum": \["low", "normal", "high"]
},
"summary": {
"type": "string",
"minLength": 1,
"maxLength": 240
},
"needsHumanReview": {
"type": "boolean"
}
},
"required": \[
"team",
"priority",
"summary",
"needsHumanReview"
]
}
Prefer enums over creative labels
If the application understands three priority levels, do not let the model invent seven.
priority: z.enum(\["low", "normal", "high"])
Without the enum, values such as urgent, critical, medium-high, or as soon as possible may all appear reasonable. Every new label moves interpretation back into application code.
Keep fields semantically focused
Avoid a catch-all field such as:
{
"result": "Billing, high priority, maybe review this"
}
It is structured only at the outermost level. The important information is still trapped in prose.
Prefer separate fields with one clear responsibility:
{
"team": "billing",
"priority": "high",
"needsHumanReview": true
}
Do not model every possibility
A schema with deeply nested alternatives, many optional properties, and overlapping meanings is difficult for humans and models alike.
If 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.
Keep instructions and structure separate
The schema defines what shape is allowed. The prompt defines how to make the decision.
For example:
Classify the support request.
Routing rules:
- Use billing for payments, invoices, refunds, and duplicate charges.
- Use account for login, profile, and subscription-access issues.
- Use technical for product errors and unavailable features.
- Use other when none of the categories fit.
Priority rules:
- Use high when the customer cannot use a paid service or reports an
active financial problem.
- Use normal when the issue affects use but has a workaround.
- Use low for questions and non-blocking requests.
Set needsHumanReview to true when evidence is incomplete, categories
conflict, or the request could cause a financial or account-level action.
The enum belongs in the schema. The business meaning of each enum belongs in the instructions or application policy.
Keeping 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.
Structured output is not business validation
Schema conformance proves that the response has the expected shape. It does not prove that the decision is correct.
This value can be perfectly valid and still be wrong:
{
"team": "technical",
"priority": "low",
"summary": "Customer reports a duplicate payment.",
"needsHumanReview": false
}
The schema cannot know that duplicate payments belong to billing. That is a business rule.
Treat validation as layers:
- Syntax validation: Is the response parseable?
- Schema validation: Does it match the expected structure?
- Business validation: Are values valid in the current domain context?
- Authorization: Is the requested action permitted?
- Human review: Does this decision require judgment or approval?
Structured output improves the interface between the model and the application. It does not remove the rest of the application's responsibilities.
Validate at the boundary
Even when a provider promises schema-conforming output, validate external data before it enters your domain logic.
export function parseTicketTriage(value: unknown): TicketTriage {
return TicketTriageSchema.parse(value);
}
For a user-facing workflow, a non-throwing result may be easier to handle:
const parsed = TicketTriageSchema.safeParse(modelOutput);
if (!parsed.success) {
logger.warn("Invalid triage response", {
issues: parsed.error.issues,
});
return sendOriginalTicketToHumanReview();
}
return routeTicket(parsed.data);
This boundary gives the application one trusted representation. Code after the parser can work with TicketTriage; code before it must treat the value as unknown.
Do not silently repair everything
It is tempting to transform almost-correct values:
const priority = output.priority === "urgent"
? "high"
: output.priority;
One carefully chosen normalization may be harmless. A growing collection of repairs becomes an undocumented second schema.
Prefer one of these responses:
- reject and retry with a clear error,
- send the item to human review,
- apply a documented normalization rule,
- use a safe default only when the product explicitly permits it.
The fallback should be part of the feature design, not an emergency branch added after deployment.
Handle uncertainty explicitly
A model will sometimes lack enough information to make a good decision. Do not force uncertainty into a confident enum.
There are several clean patterns.
Add a review flag
needsHumanReview: z.boolean()
This works when a best-effort classification is still useful but the action should pause.
Add an explicit unknown value
team: z.enum(\[
"billing",
"account",
"technical",
"other",
"unknown",
])
Use this when the absence of a reliable classification is meaningful to downstream code.
Return a discriminated union
const TriageResultSchema = z.discriminatedUnion("status", \[
z.object({
status: z.literal("classified"),
team: z.enum(\["billing", "account", "technical", "other"]),
priority: z.enum(\["low", "normal", "high"]),
summary: z.string().min(1).max(240),
}),
z.object({
status: z.literal("needs\_review"),
reason: z.string().min(1).max(240),
}),
]);
This makes success and uncertainty different states instead of mixing partially valid fields into one object.
Make failures observable
A production integration should record more than βAI request failed.β Useful signals include:
- schema-validation failure rate,
- retries per request,
- human-review rate,
- frequency of each enum value,
- latency and token usage,
- provider and model version,
- schema version,
- business-rule rejection rate.
Be 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.
Test the contract, not one impressive demo
A single successful response proves very little. Use a small evaluation set that resembles real input.
For ticket triage, include:
- a clear billing issue,
- a clear account issue,
- a message containing two unrelated problems,
- an empty or extremely short message,
- a long message with irrelevant details,
- informal language and spelling mistakes,
- text that asks the model to ignore its instructions,
- a case that should require human review.
Unit-test the schema
import { describe, expect, it } from "vitest";
const validResult = {
team: "billing",
priority: "high",
summary: "Customer reports a duplicate charge.",
needsHumanReview: true,
};
describe("TicketTriageSchema", () => {
it("accepts a valid triage result", () => {
expect(TicketTriageSchema.safeParse(validResult).success).toBe(true);
});
it("rejects an invented priority", () => {
const result = {
...validResult,
priority: "urgent",
};
expect(TicketTriageSchema.safeParse(result).success).toBe(false);
});
it("rejects unexpected fields", () => {
const result = {
...validResult,
automaticRefund: true,
};
expect(TicketTriageSchema.safeParse(result).success).toBe(false);
});
});
Evaluate semantics separately
Schema tests answer whether the payload is structurally valid. Evaluation cases answer whether the classification is useful.
Keep expected outcomes alongside representative inputs:
const cases = \[
{
input: "I was charged twice for the same month.",
expectedTeam: "billing",
expectedPriority: "high",
},
{
input: "How do I change the name shown on my profile?",
expectedTeam: "account",
expectedPriority: "low",
},
];
Run 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.
Suggested minimum contract test suite
Version the contract
Structured output becomes an internal API. Treat changes accordingly.
Adding 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.
For persisted results or asynchronous workflows, include a version:
const TicketTriageV1Schema = z.object({
schemaVersion: z.literal("1"),
team: z.enum(\["billing", "account", "technical", "other"]),
priority: z.enum(\["low", "normal", "high"]),
summary: z.string().min(1).max(240),
needsHumanReview: z.boolean(),
}).strict();
Versioning is especially useful when:
- responses are stored in a database,
- jobs are processed asynchronously,
- more than one service consumes the output,
- a deployment may read results created by an older release,
- evaluations compare behavior across model or prompt changes.
Know when not to use structured output
Not every model response needs a schema.
Free-form text is often appropriate for:
- brainstorming,
- drafting an article,
- explaining code to a developer,
- conversational answers shown directly to a person,
- creative transformations where variation is the point.
Structured output becomes valuable when:
- code branches on the result,
- data is stored or indexed,
- the response feeds another API,
- a UI renders known components from the result,
- a tool call or workflow step depends on specific fields,
- failures must be measured and handled consistently.
A useful question is:
Will a machine consume this response before a person approves it?
If the answer is yes, a schema is usually worth considering.
Production checklist
Before shipping a structured-output feature, check the complete boundary:
Contract
- [ ] The consumer defines the required fields.
- [ ] Enums represent application-supported values.
- [ ] Optional fields have clear semantics.
- [ ] Unknown or review states are explicit.
- [ ] The schema rejects unnecessary properties.
Runtime
- [ ] External output is treated as
unknownuntil validated. - [ ] Schema validation is separate from business validation.
- [ ] Invalid responses use a documented fallback.
- [ ] Refusals, validation errors, and transport errors remain distinct.
- [ ] High-impact actions require authorization or human approval.
Testing
- [ ] Schema edge cases have unit tests.
- [ ] Representative inputs have expected outcomes.
- [ ] Prompt, schema, and model changes trigger evaluations.
- [ ] Failure paths are tested, not only successful responses.
Operations
- [ ] Schema or contract versions are recorded.
- [ ] Validation and review rates are observable.
- [ ] Logs do not expose sensitive model input or output.
- [ ] Alerts reflect user impact rather than raw provider errors alone.
The takeaway
Prompts are instructions. Schemas are contracts.
A 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.
The dependable pattern is straightforward:
Define the consumer
β
Design a small schema
β
Generate structured output
β
Validate at the boundary
β
Apply business rules
β
Continue, retry, or request review
Structured output does not make a model infallible. It makes the integration easier to reason about.
You 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.β
What kind of AI response does your application still parse from free-form text?
Explore the official JSON Schema documentation
Sources and further reading
- JSON Schema: Official documentation and ecosystem
- JSON Schema overview: What is JSON Schema?
- OpenAI: Structured model outputs
- Google: Structured outputs for the Gemini API
Connect with Me
If you found this guide helpful, let's connect and discuss modern development workflows!
- π» GitHub: johnnylemonny
- βοΈ DEV.to: johnnylemonny
Top comments (0)