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.
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.
Imagine a support form that accepts an unstructured customer message. We want an AI model to suggest:
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.
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:
team
and priority
must use known values,summary
must always exist,needsHumanReview
must 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.
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:
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"
]
}
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.
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
}
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.
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.
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:
Structured output improves the interface between the model and the application. It does not remove the rest of the application's responsibilities.
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
.
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:
The fallback should be part of the feature design, not an emergency branch added after deployment.
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.
needsHumanReview: z.boolean()
This works when a best-effort classification is still useful but the action should .
team: z.enum(\[
"billing",
"account",
"technical",
"other",
"unknown",
])
Use this when the absence of a reliable classification is meaningful to downstream code.
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.
A production integration should record more than “AI request failed.” Useful signals include:
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.
A single successful response proves very little. Use a small evaluation set that resembles real input.
For ticket triage, include:
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);
});
});
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 suiteStructured 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:
Not every model response needs a schema.
Free-form text is often appropriate for:
Structured output becomes valuable when:
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.
Before shipping a structured-output feature, check the complete boundary:
unknown
until validated.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
If you found this guide helpful, let's connect and discuss modern development workflows!