Most AI apps are built prompt-first: write a prompt, hope the output is useful, parse it somehow. Schema-first design flips this: define your output schema first, then build the prompt around it.
The approach
1. Define the output schema (what you need)
2. Build the prompt to produce that schema
3. Use structured outputs to enforce it
4. Type-check everything downstream
Example: AI code review tool
Schema first
import { z } from 'zod';
const CodeReviewSchema = z.object({
issues: z.array(z.object({
severity: z.enum(["critical", "warning", "info"]),
file: z.string(),
line: z.number(),
description: z.string(),
suggestion: z.string()
})),
summary: z.string(),
score: z.number().min(0).max(100)
});
type CodeReview = z.infer<typeof CodeReviewSchema>;
Then the prompt
const response = await client.chat.completions.create({
model: "gpt-5.4",
response_format: {
type: "json_schema",
json_schema: { schema: zodToJsonSchema(CodeReviewSchema) }
},
messages: [{
role: "system",
content: "Review the code. Return issues with severity, file, line, description, and suggestion."
}, {
role: "user",
content: codeToReview
}]
});
const review: CodeReview = CodeReviewSchema.parse(JSON.parse(response.choices[0].message.content));
// Fully typed, validated, guaranteed to match schema
Why this works
- No parsing surprises — structured outputs enforce the schema
- Type safety — TypeScript/Python types flow from the schema
- Testable — You can unit test against the schema
- Evolvable — Change the schema, update the prompt, everything stays consistent
- Debuggable — When output is wrong, you know exactly which field failed
For MCP servers
MCP tool definitions are schema-first by design — you define input parameters with Zod/JSON Schema, and the protocol enforces them. This is why MCP tools are more reliable than free-form prompting.
For RAG systems
Define schemas for your retrieval results:
const SearchResultSchema = z.object({
answer: z.string(),
sources: z.array(z.object({
title: z.string(),
url: z.string(),
relevance: z.number()
})),
confidence: z.enum(["high", "medium", "low"])
});
Now your RAG pipeline always returns structured, typed results.
Related: Structured Outputs Explained · JSON Mode vs Structured Outputs · Why Parsing LLM Output Breaks · MCP Complete Guide