Designing APIs That Return Predictable JSON
Why we spend as much time on response schemas as we do on model outputs—and how structured JSON saves developers hours of debugging.
When we started building RedAPI, we made a decision that seemed obvious in retrospect but wasn’t common in AI tooling at the time:
We would treat response schemas as a first-class concern, not an afterthought.
This post explains why that decision matters and how it affects our API design.
The Problem with AI Output
Traditional AI outputs are strings. Sometimes they’re formatted (markdown, JSON strings), but the API still returns text that you have to parse. This leads to:
- Parsing errors — The model didn’t output valid JSON
- Schema drift — Keys appear/disappear based on content
- Type instability — A field might be a string or an array depending on context
- Missing fields — Optional fields are sometimes omitted
Here’s a real example from a hypothetical AI API:
// Request: "Summarize these reviews"
// Response 1 (happy path):
{
"summary": "Customers like the product quality but complain about shipping",
"sentiment": "mixed"
}
// Response 2 (when reviews are all negative):
{
"verdict": "negative",
"issues": ["slow shipping", "damaged product", "poor support"]
}
// Response 3 (when reviews are all positive):
{
"summary": "Customers are very happy with the product"
}
Notice: different response shapes, different field names (summary vs verdict, sentiment vs the absence of it), inconsistent types.
This is a debugging nightmare.
Our Approach
Every RedAPI endpoint has a response schema that’s documented and enforced. The Review Summarizer always returns:
interface ReviewSummary {
summary: string;
sentiment_score: number; // Always 0-1
pros: string[]; // Always an array
cons: string[]; // Always an array
recommendation: string | null; // Can be null, not missing
}
No matter what input you send, the output shape is consistent. You can rely on:
const { pros, cons, sentiment_score } = await summarizeReviews(reviews);
// TypeScript knows these exist and their types
Use our generated types to get full IDE support:
import type { ReviewSummary } from 'redapi/types';
const result: ReviewSummary = await summarizeReviews(reviews); Why This Matters for Production Code
Consider error handling. With inconsistent schemas:
// What you write:
if (response.sentiment) {
sendAlert(response.sentiment);
}
// What happens when sentiment is missing:
// No alert sent, silently fails
With consistent schemas:
// What you write:
if (response.sentiment_score < 0.3) {
sendAlert(`Low sentiment: ${response.sentiment_score}`);
}
// What happens:
// Alert sent when score is low, works reliably
The second version is predictable. You know what you’re getting. You can write reliable code.
Schema Design Principles
We apply three principles to every response schema:
1. Always Present Required Fields
If a field is always present in the response, it’s in the schema. No null-checks needed:
// Instead of: pros?: string[]
// We use: pros: string[] (always present, possibly empty)
2. Typed Numeric Ranges
Sentiment scores are always 0-1, not “positive/negative/neutral”. Percentages are always 0-100, not “high/medium/low”. This lets you do math on the output:
const avgScore = reviews.reduce((sum, r) => sum + r.sentiment_score, 0) / reviews.length;
3. Explicit Null vs Missing
We distinguish between “not applicable” (null) and “not present” (missing). If a field can be null, it’s explicitly null in the schema:
recommendation: string | null // Explicitly nullable
The Implementation
Achieving predictable JSON isn’t free. It requires:
- Output validation — We validate model outputs against the schema before returning
- Schema-driven generation — The model is guided to produce schema-compliant output
- Fallback defaults — If the model can’t produce a field, we provide a sensible default
function validateAndNormalize(output, schema) {
return {
...schema.defaults,
...output,
// Normalize types
pros: Array.isArray(output.pros) ? output.pros : [output.pros].filter(Boolean),
sentiment_score: normalizeToFloat(output.sentiment_score, 0, 1)
};
}
The Developer Experience
The payoff is in how easy it is to use our APIs:
// Clean, predictable, typed
const { sentiment_score, pros, cons } = await summarizeReviews(reviews);
console.log(`Average rating: ${(sentiment_score * 5).toFixed(1)}/5`);
console.log(`Top complaint: ${cons[0]}`);
// Works every time, no undefined checks
That’s the experience we’re building toward. APIs that get out of your way and let you build.