Episode 4 — Generative AI Engineering / 4.5 — Generating JSON Responses from LLMs

4.5.b — Schema-Based Prompting

In one sentence: Even with JSON mode enabled, the model doesn't know which fields you want — you must include a schema, example, or type definition in your prompt to guide the model toward the exact JSON structure your code expects.

Navigation: ← 4.5.a — JSON Mode · 4.5.c — Function Calling Basics →


1. Why the Prompt Needs a Schema

JSON mode (4.5.a) guarantees valid syntax. But syntax is not structure. Consider:

// You wanted this:
{ "first_name": "Alice", "last_name": "Smith", "age": 30 }

// The model returned this (valid JSON, wrong structure):
{ "name": "Alice Smith", "years_old": 30 }

// Or this:
{ "user": { "name": { "first": "Alice", "last": "Smith" }, "age": "30" } }

All three are valid JSON. Only the first one matches what your downstream code expects. The model has no way to know your preferred key names, nesting structure, or data types unless you tell it in the prompt.

Schema-based prompting is the technique of embedding a structural specification directly in your prompt to guide the model's output format.


2. Strategy 1: Show the Exact JSON Structure

The simplest and most effective approach — show the model exactly what you want:

const response = await openai.chat.completions.create({
  model: 'gpt-4o',
  temperature: 0,
  response_format: { type: 'json_object' },
  messages: [
    {
      role: 'system',
      content: `You are a profile data extractor. Extract user information and return it in the following JSON structure:

{
  "first_name": "string",
  "last_name": "string",
  "age": number,
  "interests": ["string", "string"],
  "location": "string or null"
}

Return ONLY this JSON structure. Fill in actual values from the user's text.`
    },
    {
      role: 'user',
      content: 'Hey, I\'m Alice Smith, 30, from Portland. I enjoy painting and cycling.'
    }
  ],
});

const data = JSON.parse(response.choices[0].message.content);
// { first_name: "Alice", last_name: "Smith", age: 30, interests: ["painting", "cycling"], location: "Portland" }

Why this works

The model sees the exact key names, nesting, and data types. It pattern-matches against this template and fills in the values. This is simple, readable, and works well for straightforward structures.


3. Strategy 2: Provide a Concrete Example (Few-Shot)

Instead of (or in addition to) a schema template, show a complete, filled-in example:

const systemPrompt = `You extract event information from text and return JSON.

Here is an example:

Input: "Join us for the Annual Tech Meetup on March 15th, 2025 at 7 PM. Location: Downtown Hub, 123 Main St. Free entry, RSVP required."

Output:
{
  "event_name": "Annual Tech Meetup",
  "date": "2025-03-15",
  "time": "19:00",
  "location": {
    "venue": "Downtown Hub",
    "address": "123 Main St"
  },
  "price": 0,
  "rsvp_required": true
}

Now extract the event information from the user's text using the same JSON format.`;

const response = await openai.chat.completions.create({
  model: 'gpt-4o',
  temperature: 0,
  response_format: { type: 'json_object' },
  messages: [
    { role: 'system', content: systemPrompt },
    {
      role: 'user',
      content: 'The Summer BBQ Bash is happening July 4th at noon in Central Park. Tickets are $15, no reservation needed.'
    }
  ],
});

const event = JSON.parse(response.choices[0].message.content);
// {
//   event_name: "Summer BBQ Bash",
//   date: "2025-07-04",
//   time: "12:00",
//   location: { venue: "Central Park", address: null },
//   price: 15,
//   rsvp_required: false
// }

When to use examples vs templates

ApproachBest ForDownside
Template (Strategy 1)Simple, flat structuresAmbiguous types ("string" could mean anything)
Concrete example (Strategy 2)Complex, nested structuresModel might over-fit to example values
Both togetherProduction-critical extractionMore tokens in the prompt

4. Strategy 3: Field Descriptions in the Prompt

Add descriptions to each field to guide the model's judgment, especially for ambiguous fields:

const systemPrompt = `You analyze dating profiles and return structured JSON.

Return a JSON object with these fields:

- "name" (string): The person's display name exactly as written
- "age" (integer): Their age as a number, not a string
- "bio_summary" (string): A 1-2 sentence summary of their bio in third person
- "interests" (array of strings): List of distinct hobbies/interests, max 5, lowercase
- "looking_for" (string): One of "relationship", "casual", "friendship", or "unclear"
- "red_flags" (array of strings): Any concerning patterns, empty array if none
- "conversation_starters" (array of strings): 2-3 suggested opening messages based on their interests

Important rules:
- "interests" should be deduplicated and normalized (e.g., "hiking" not "I love to hike")
- "looking_for" must be exactly one of the four allowed values
- "red_flags" should only include genuinely concerning patterns, not preferences
- "conversation_starters" should reference specific details from their profile`;

Why descriptions matter

Without descriptions, the model makes its own decisions:

// Without description: model guesses what "interests" means
{ "interests": ["I really enjoy hiking on weekends with my dogs"] }  // Full sentences
{ "interests": ["hiking", "dogs", "weekends"] }                      // Too granular
{ "interests": ["Hiking", "Dog Walking", "Outdoor Activities"] }     // Title case, redundant

// With description "array of strings, max 5, lowercase, distinct hobbies":
{ "interests": ["hiking", "dog walking", "cooking"] }                // Correct format

5. Strategy 4: TypeScript-Style Type Definitions

For developers and complex structures, TypeScript-style definitions are extremely effective. LLMs trained on code data understand them natively:

const systemPrompt = `You are a profile analysis engine. Analyze dating profiles and return structured data.

Return a JSON object matching this TypeScript type:

type ProfileAnalysis = {
  // Basic info extracted from profile
  name: string;
  age: number;
  location: string | null;

  // Analysis results
  personality_traits: string[];      // 3-5 dominant traits, e.g. ["adventurous", "intellectual"]
  interests: {
    category: string;                // "outdoors", "arts", "tech", "sports", "food", "social"
    items: string[];                 // specific interests in this category
  }[];

  // Compatibility assessment
  compatibility: {
    score: number;                   // 0-100 integer
    strengths: string[];             // 2-4 positive compatibility factors
    concerns: string[];              // 0-3 potential issues
  };

  // Conversation suggestions
  openers: string[];                 // 2-3 personalized opening messages

  // Metadata
  confidence: "high" | "medium" | "low";  // How confident the analysis is
};

Respond with ONLY the JSON object matching this type.`;

const response = await openai.chat.completions.create({
  model: 'gpt-4o',
  temperature: 0,
  response_format: { type: 'json_object' },
  messages: [
    { role: 'system', content: systemPrompt },
    {
      role: 'user',
      content: `Profile: "Hi! I'm Maya, 27, SF. Software engineer by day, rock climber by 
      weekend. Love spicy food, indie films, and debating philosophy over coffee. Looking 
      for someone who can keep up on the trail and in conversation. No couch potatoes please!"`
    }
  ],
});

const analysis = JSON.parse(response.choices[0].message.content);
console.log(analysis.compatibility.score);      // 82
console.log(analysis.personality_traits);        // ["adventurous", "intellectual", "active", "opinionated"]
console.log(analysis.interests[0].category);     // "outdoors"

Why TypeScript types work so well

  1. LLMs have seen millions of TypeScript definitions in training data — they understand the syntax deeply.
  2. Union types ("high" | "medium" | "low") communicate enum constraints clearly.
  3. Nullable types (string | null) tell the model when null is acceptable.
  4. Inline comments (// 0-100 integer) add context right where it's needed.
  5. Nested types communicate structure unambiguously.

6. Strategy 5: System Message for Schema, User Message for Data

A clean separation pattern: put the structural specification in the system message and the data to analyze in the user message:

// CLEAN SEPARATION
const messages = [
  {
    role: 'system',
    content: `You extract structured data from restaurant reviews.
    
Return JSON matching this schema:
{
  "restaurant_name": "string",
  "rating": "number (1-5)",
  "cuisine": "string",
  "price_range": "$" | "$$" | "$$$" | "$$$$",
  "highlights": ["string"],
  "complaints": ["string"],
  "would_return": "boolean"
}

Rules:
- rating should reflect the overall sentiment, 1 = terrible, 5 = excellent
- highlights and complaints should each have 1-3 items
- if the reviewer doesn't mention price, infer from context or use "$$" as default`
  },
  {
    role: 'user',
    content: `Review: "Tried Sakura Sushi last night. The omakase was absolutely 
    incredible - freshest fish I've had in years. Chef's special rolls were creative 
    and beautifully plated. Only downside was the 45-minute wait even with a 
    reservation, and the bill was eye-watering at $200 per person. Still, 
    I'd go back in a heartbeat for a special occasion."`
  }
];

Why this separation matters

  1. System message is persistent — it's included in every API call. Putting the schema here means you define it once and reuse it across many user inputs.
  2. User message is variable — different data each time, same extraction schema.
  3. Easier to test — you can swap user messages without changing the system setup.
  4. Clearer intent — the model understands "system = rules, user = data" naturally from its training.

Anti-pattern: Mixing schema and data

// MESSY — schema and data jumbled together
const messages = [
  {
    role: 'user',
    content: `Here's a restaurant review: "Tried Sakura Sushi last night..." 
    Please extract the restaurant name as a string, rating as 1-5, cuisine type, 
    price range as $-$$$$, key highlights as an array, complaints as an array, 
    and whether they'd return as a boolean. Return as JSON.`
  }
];
// This works but is harder to maintain, test, and reuse

7. Handling Optional Fields

Real-world data is messy. Some fields may not be present in the source text. Here's how to communicate optionality:

const systemPrompt = `Extract contact information from text. Return JSON.

Schema:
{
  "name": "string (REQUIRED)",
  "email": "string (REQUIRED)",
  "phone": "string or null (optional - null if not provided)",
  "company": "string or null (optional - null if not provided)",
  "title": "string or null (optional - null if not provided)",
  "social_links": {
    "linkedin": "string or null",
    "twitter": "string or null",
    "github": "string or null"
  }
}

Rules:
- ALWAYS include all fields, even if null
- Never omit a field — use null for missing data
- Never invent data — if the email isn't in the text, ask (don't guess)
- Phone numbers should be in E.164 format if possible`;

// Example with sparse data
const response = await openai.chat.completions.create({
  model: 'gpt-4o',
  temperature: 0,
  response_format: { type: 'json_object' },
  messages: [
    { role: 'system', content: systemPrompt },
    {
      role: 'user',
      content: 'Contact: Bob Chen, bob@example.com, LinkedIn: linkedin.com/in/bobchen'
    }
  ],
});

const contact = JSON.parse(response.choices[0].message.content);
// {
//   name: "Bob Chen",
//   email: "bob@example.com",
//   phone: null,
//   company: null,
//   title: null,
//   social_links: {
//     linkedin: "linkedin.com/in/bobchen",
//     twitter: null,
//     github: null
//   }
// }

Three approaches to optional fields

ApproachPrompt InstructionResult
Null for missing"Use null if not mentioned"{ "phone": null } — consistent, easy to check
Omit if missing"Only include fields found in text"{ } — smaller JSON, but harder to validate
Default value"Use 'N/A' if not found"{ "phone": "N/A" } — easy to display, harder to check programmatically

Recommendation: Use null for missing — it's the most programmatically friendly. Your code can do if (data.phone !== null) cleanly.


8. Multi-Example Schema Prompting (Few-Shot)

For complex or ambiguous extraction tasks, provide multiple examples that cover edge cases:

const systemPrompt = `You classify support tickets. Return JSON.

Example 1 (clear category):
Input: "My payment was charged twice for order #12345"
Output: { "category": "billing", "priority": "high", "order_id": "12345", "sentiment": "frustrated" }

Example 2 (ambiguous — choose best fit):
Input: "The app crashes whenever I try to upload a photo"
Output: { "category": "bug", "priority": "high", "order_id": null, "sentiment": "neutral" }

Example 3 (positive feedback, not a problem):
Input: "Just wanted to say the new dashboard design is amazing!"
Output: { "category": "feedback", "priority": "low", "order_id": null, "sentiment": "positive" }

Example 4 (multiple issues — pick the primary one):
Input: "I can't log in and also my last order never arrived, order #99999"
Output: { "category": "account", "priority": "high", "order_id": "99999", "sentiment": "frustrated" }

Now classify the user's ticket using the same JSON format.`;

How many examples?

CountWhen
0 (zero-shot)Simple, unambiguous tasks
1-2Most extraction tasks
3-5Ambiguous categories, edge cases to demonstrate
5+Complex classification with many categories

Trade-off: More examples = better accuracy but more input tokens (higher cost, less room in context window).


9. Advanced: Combining Strategies

In production, combine multiple strategies for maximum reliability:

const systemPrompt = `You are a job posting analyzer. Extract structured data from job postings.

## Output Schema (TypeScript)

type JobAnalysis = {
  title: string;                         // Exact job title from posting
  company: string;                       // Company name
  location: {
    city: string | null;
    state: string | null;
    country: string;
    remote: "remote" | "hybrid" | "onsite" | "unclear";
  };
  salary: {
    min: number | null;                  // Annual salary in USD
    max: number | null;
    currency: string;                    // ISO 4217 code, default "USD"
  } | null;                              // null if no salary info
  requirements: {
    years_experience: number | null;     // Minimum years, null if not specified
    skills: string[];                    // Technical skills, lowercase
    education: string | null;            // Minimum education level
  };
  benefits: string[];                    // Listed benefits
  red_flags: string[];                   // Concerning patterns (vague role, "fast-paced" = overwork, etc.)
};

## Example

Input: "Senior React Developer at TechCorp. NYC, hybrid 3 days/week. $150K-$180K. 5+ years React experience required. BS in CS preferred. Benefits: health, dental, 401k, unlimited PTO."

Output:
{
  "title": "Senior React Developer",
  "company": "TechCorp",
  "location": { "city": "New York", "state": "NY", "country": "US", "remote": "hybrid" },
  "salary": { "min": 150000, "max": 180000, "currency": "USD" },
  "requirements": { "years_experience": 5, "skills": ["react"], "education": "BS in Computer Science" },
  "benefits": ["health insurance", "dental insurance", "401k", "unlimited PTO"],
  "red_flags": ["unlimited PTO can mean no PTO tracking"]
}

## Rules
- skills array: lowercase, deduplicated, specific (not "programming")
- salary: convert to annual USD, null if hourly/contract with unclear hours
- red_flags: be specific, not generic`;

This combines:

  • TypeScript types for precise structure definition
  • Inline comments for field-level guidance
  • A concrete example for pattern demonstration
  • Explicit rules for edge cases

10. Key Takeaways

  1. JSON mode ensures syntax; schema prompting ensures structure. You need both.
  2. Show the exact structure you want — key names, nesting, and types in the prompt.
  3. TypeScript-style type definitions are extremely effective because LLMs understand them natively.
  4. Concrete examples (few-shot) eliminate ambiguity better than descriptions alone.
  5. Put the schema in the system message, data in the user message — clean separation enables reuse and testing.
  6. Handle optional fields explicitly — tell the model to use null for missing data rather than omitting fields.
  7. Combine strategies for production: TypeScript types + example + rules = maximum reliability.

Explain-It Challenge

  1. You're getting back { "user_name": "Alice" } but your code expects { "username": "Alice" }. How do you fix this without changing your code?
  2. When would you use TypeScript-style type definitions vs a concrete JSON example in the prompt? What are the strengths of each?
  3. A teammate asks why you're "wasting tokens" by putting a long schema in the system prompt. How do you justify the cost?

Navigation: ← 4.5.a — JSON Mode · 4.5.c — Function Calling Basics →