Module 2: Structured Outputs and Tools · 40 min

Structured Outputs With JSON Schema

Learning goal

Turn this lesson into one checked practice output

By the end, you should be able to explain the core idea behind “Structured Outputs With JSON Schema” in your own words, apply it to one small real or sample task, and identify what still needs human review.

  1. 1

    Learn

    Read the 40-minute lesson without copying an output blindly.

  2. 2

    Try

    Use a small, non-sensitive example that you can inspect line by line.

  3. 3

    Review

    Check facts, fit, and risk; save one improvement note for next time.

Structured output turns a model response from prose that a human might understand into data that software can validate before using. JSON alone is not enough: {"priority":"extremely urgent"} is valid JSON but may violate your application's allowed categories. A JSON Schema defines the contract—fields, types, required values, permitted categories, and whether unexpected properties are rejected. The model proposes data; your application validates meaning and decides what happens next.

Structured output is appropriate when another system will consume the result: classifying support tickets, extracting fields from approved documents, drafting a product record, or generating an analysis object. It does not make a false fact true. Grounding, authorization, business checks, and human review still matter.

Start With the Consumer, Not the Prompt

Before writing a schema, identify the next component. Suppose a small ecommerce team wants to triage synthetic support messages. The queue accepts three priorities, requires a short reason, and needs an explicit boolean showing whether a human must review the result. That downstream need determines the shape:

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "title": "SupportTriage",
  "type": "object",
  "properties": {
    "category": {
      "type": "string",
      "enum": ["delivery", "product", "payment", "other"]
    },
    "priority": {
      "type": "string",
      "enum": ["low", "normal", "high"]
    },
    "reason": {
      "type": "string",
      "minLength": 10,
      "maxLength": 180
    },
    "needs_human_review": {
      "type": "boolean"
    },
    "evidence_phrases": {
      "type": "array",
      "items": { "type": "string", "minLength": 1, "maxLength": 80 },
      "minItems": 1,
      "maxItems": 3
    }
  },
  "required": [
    "category",
    "priority",
    "reason",
    "needs_human_review",
    "evidence_phrases"
  ],
  "additionalProperties": false
}

Every required field has a type. Enumerations stop category drift. Length limits reduce unbounded output. additionalProperties: false prevents an apparently helpful model from inventing fields the consumer silently ignores. The evidence array asks the extractor to point back to supplied text; your code must still confirm each phrase actually appears in that text.

Do not represent “unknown” by quietly omitting a required field unless the consumer understands omission. Choose a deliberate design: a nullable field, an unknown enum member, or a separate needs_human_review flag. Required does not mean “the model must guess.”

Worked Example: Validate Fixtures Before Calling a Model

Save the schema as triage.schema.json, then install a general JSON Schema validator:

npm install ajv

Create validate.mjs:

import { readFile } from "node:fs/promises";
import Ajv2020 from "ajv/dist/2020.js";

const schema = JSON.parse(await readFile("triage.schema.json", "utf8"));
const ajv = new Ajv2020({ allErrors: true });
const validate = ajv.compile(schema);

const fixtures = [
  {
    name: "valid",
    value: {
      category: "product",
      priority: "normal",
      reason: "The synthetic message reports that the received colour is wrong.",
      needs_human_review: true,
      evidence_phrases: ["wrong colour"]
    }
  },
  {
    name: "invalid-priority-and-extra-field",
    value: {
      category: "product",
      priority: "urgent-now",
      reason: "Wrong colour was reported.",
      needs_human_review: true,
      evidence_phrases: ["wrong colour"],
      promised_refund: true
    }
  },
  {
    name: "invalid-empty-evidence",
    value: {
      category: "product",
      priority: "normal",
      reason: "The synthetic message reports that the received colour is wrong.",
      needs_human_review: true,
      evidence_phrases: []
    }
  }
];

let failed = false;
for (const fixture of fixtures) {
  const ok = validate(fixture.value);
  console.log(fixture.name, ok ? "PASS" : "FAIL");
  if (!ok) console.log(validate.errors);
  if ((fixture.name === "valid") !== ok) failed = true;
}

if (failed) process.exitCode = 1;

Run node validate.mjs. The first fixture should pass. The second should fail for the unsupported enum value and extra property. This local test costs nothing, works offline, and proves the schema behaves before a probabilistic component is introduced.

Now add a semantic check that JSON Schema cannot express easily:

function evidenceExists(sourceText, result) {
  if (!Array.isArray(result.evidence_phrases) || result.evidence_phrases.length === 0) {
    return false;
  }
  const source = sourceText.toLocaleLowerCase();
  return result.evidence_phrases.every((phrase) => {
    if (typeof phrase !== "string" || phrase.trim().length === 0) return false;
    return source.includes(phrase.trim().toLocaleLowerCase());
  });
}

Run it against the exact approved input. A model could return schema-valid evidence that never appeared in the source. Syntax, schema, and evidence are three separate gates.

Connect the Same Contract to a Model

Providers expose structured outputs differently. OpenAI's current Responses API documentation shows SDK helpers that pair a language-native schema library with the request and return parsed output. A minimal JavaScript shape is:

import OpenAI from "openai";
import { z } from "zod";
import { zodTextFormat } from "openai/helpers/zod";

const Triage = z.object({
  category: z.enum(["delivery", "product", "payment", "other"]),
  priority: z.enum(["low", "normal", "high"]),
  reason: z.string().min(10).max(180),
  needs_human_review: z.boolean(),
  evidence_phrases: z.array(z.string().trim().min(1).max(80)).min(1).max(3),
});

const client = new OpenAI();
const response = await client.responses.parse({
  model: process.env.OPENAI_MODEL,
  input: [
    { role: "system", content: "Classify only from the supplied synthetic message. Mark uncertainty for human review." },
    { role: "user", content: "My demo order arrived with the wrong colour." }
  ],
  text: { format: zodTextFormat(Triage, "support_triage") },
});

console.log(response.output_parsed);

Use a currently supported model and current SDK versions from official documentation. Treat refusals, incomplete responses, parse failures, timeouts, and empty outputs as explicit branches. Validate again at the boundary if the parsed object crosses into a different service. Never let “the SDK parsed it” become authorization to refund, charge, publish, or message somebody.

Schema Design Failure Cases

  • Valid structure, false content: require source pointers and verify them. Use human review for consequential decisions.
  • Schema too loose: free-form strings for status or currency create dozens of spellings. Use controlled enums where the business rules are genuinely finite.
  • Schema too rigid: a city enum copied from today's service area rejects a newly approved location. Version the contract and update it through a controlled change.
  • Numbers used for identifiers: phone numbers, CNIC-like identifiers, postal codes, and order IDs can contain leading zeros or punctuation. Store identifiers as strings and minimize sensitive fields.
  • Money stored as floating point: represent amounts using an agreed smallest unit or a decimal-safe strategy, along with an explicit currency code. Do not ask the model to infer exchange rates.
  • Unknown forced into a guess: include an uncertainty path and escalation field.
  • Schema changed without consumers: adding a required field can break older workers. Version schemas and test both producer and consumer.
  • Validator errors hidden: record the schema version, failure path, and safe reason; do not log the private payload by default.

🇵🇰 Pakistan Angle

Pakistan-facing records need deliberate formats. Preserve Urdu and Roman Urdu as UTF-8. Store phone numbers and identifiers as strings, not numeric values. Use explicit currency codes such as PKR rather than assuming every number is rupees, and define whether an amount includes tax or delivery instead of letting a model decide. Test commas in amounts, day-month-year ambiguity, city spelling variants, and mixed English/Roman-Urdu evidence.

For a low-cost workflow, run schemas and fixtures locally before making any API request. Use synthetic order IDs and messages during development. If a client supplies customer chats or order exports, minimize fields, obtain authorization, document retention, and keep raw records outside the source repository. A schema should exclude private data that the task does not need, not merely describe it neatly.

Hands-On Exercise: Build a Contract That Rejects Bad Data

Choose one low-risk task: support triage, content-brief extraction, or product-record drafting. Deliver:

  1. result.schema.json with a title, explicit types, every field intentionally required or nullable, bounded strings/arrays, enums where justified, and additionalProperties: false.
  2. Three valid fixtures: normal, boundary, and explicit uncertainty.
  3. At least five invalid fixtures: missing field, wrong type, unknown enum, extra field, and overlong text.
  4. validate.mjs that exits non-zero if any fixture behaves differently from expectation.
  5. One semantic validator that checks a business fact the schema cannot prove, such as every evidence phrase existing in the source.
  6. SCHEMA-NOTES.md naming the consumer, schema version, sensitive fields deliberately excluded, and migration plan for one hypothetical change.

Do not make a live model request until all fixtures pass. If you optionally connect a provider, save only redacted result fixtures and record the model configuration and schema version.

Completion Rubric

  • Pass — contract clarity: a developer can identify allowed fields, types, categories, limits, and unknown handling without reading the prompt.
  • Pass — rejection proof: all invalid fixtures fail for the intended reason and make the test command exit appropriately.
  • Pass — semantic proof: at least one check goes beyond JSON validity and confirms supplied evidence or a business invariant.
  • Pass — privacy: the schema minimizes personal data and the repository contains only synthetic fixtures.
  • Pass — change safety: the schema has a version and a documented consumer migration plan.
  • Needs revision: valid JSON is treated as verified truth, required fields force invention, or extra properties pass unnoticed.

Sources


Key takeaway: Structured output is a versioned contract, not a truth machine: define the consumer's schema, reject malformed and unexpected data, verify source evidence separately, and keep consequential actions behind authorization and human review.

Self-check

Before you mark Lesson 2.1 complete

  • Can I explain “Structured Outputs With JSON Schema” without reading the lesson back word for word?
  • Did I complete the lesson’s practice step on a real or clearly labelled sample task?
  • Did I check the result for invented facts, private data, unsafe actions, and mismatch with the brief?