Back to Blog
Engineering March 30, 2026 10 min read

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:

  1. Parsing errors — The model didn’t output valid JSON
  2. Schema drift — Keys appear/disappear based on content
  3. Type instability — A field might be a string or an array depending on context
  4. 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
TypeScript Tip

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:

  1. Output validation — We validate model outputs against the schema before returning
  2. Schema-driven generation — The model is guided to produce schema-compliant output
  3. 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.