Structured Outputs in TypeScript
Introduction: Grounding Factor 1 in Practice
In the previous course, you learned about Factor 1 (Natural Language to Tool Calls) of the 12-Factor Agents methodology: translating natural language into machine-readable actions. This unit begins with prompted JSON: we ask the model for a particular shape, but the API does not enforce it. The next unit introduces native function calls with a strict tool schema. OpenAI calls that schema-enforced feature Structured Outputs; a prompt alone does not provide the same guarantee.
Now, it is time to implement that principle. In this lesson, you will learn a probabilistic approach for obtaining structured outputs: prompting OpenAI models to return JSON. This method relies on careful instruction through the system prompt, but it is important to understand that this is a best-effort technique — the model will usually comply, but there is no guarantee. You will need robust error handling to manage cases where the model does not follow instructions perfectly.
The System Prompt Pattern
The first step in obtaining structured outputs is crafting a system prompt that clearly instructs the model on which format to use. Think of the system prompt as setting the rules of the game before the conversation starts. Here is a simple but effective pattern:
Notice what this prompt does. It does not simply ask the model to "return JSON" in a vague way. It shows the exact JSON schema you expect, complete with field names and a brief description of the content for each field. The word "only" is deliberate — it reinforces that the model should not add extra commentary or explanations outside the JSON structure.
This approach leverages the model's training on structured data formats; therefore, when you provide a clear schema, the model generally complies. However, remember that this is a best-effort instruction — the model may still occasionally deviate from the schema or return invalid JSON. This systemPrompt string will be passed directly to the instructions field of the API request, as you will see in the next section.
Making the API Request
With your systemPrompt ready, you can now make a request using the OpenAI SDK. First, you create a client instance — it automatically reads your OPENAI_API_KEY from the environment, eliminating the need for manual configuration:
Let's break down each parameter used in the request:
model: Specifies whichOpenAImodel to use — in this case,"gpt-5", which is one of the more capable models.instructions: Takes yoursystemPrompt, which sets the behavioral constraints for the entire conversation.input: An array of message objects representing the conversation history, where each message has arole(such as"user"or"assistant") andcontent(the actual text).reasoning: Setting this to"low"effort is appropriate for straightforward tasks like this math question. More complex reasoning would use"medium"or"high"but consume more tokens.
Now that we have a response, we need to extract the structured data from it.
Navigating the Response Structure
Once the API call resolves, you need to extract the actual JSON data the model produced. The response object has a nested structure that requires careful navigation, and the most important property is response.output — an array of output items.
Each item could be a message, a function call, or another type of content. You iterate through these items and check whether the type is "message", which indicates a text response from the model. Inside a message, the content is itself an array, so you read the first content item and confirm its type is "output_text" before accessing its text:
The two type checks do double duty in TypeScript. Because response.output can hold structurally different items, if (item.type === "message") narrows the item so you can safely access item.content. Likewise, content?.type === "output_text" narrows the content part so you can reach content.text. The optional chaining content?. guards against an empty content array. The resulting text should be a valid JSON string matching your schema — but because this is a probabilistic approach, that cannot be taken for granted. The next step is to parse this string and extract the data you require.
Parsing and Extracting the Answer
Next is the critical step: converting text from a plain string into a typed TypeScript object so that you can access the structured data. This step checks whether the text is valid JSON. It does not yet validate that the parsed value has the required answer field or that the field is a string.
JSON.parse() converts the raw string into a JavaScript object. The type assertion as { answer?: string } only informs TypeScript of the expected shape at compile time; it performs no runtime check — note that answer is typed as optional (answer?) because even when parsing succeeds, the field might be missing if the model did not follow the schema exactly.
By accessing result.answer, you extract the specific value you need. For our addition problem, the terminal will display the final calculated result:
However, because this prompting approach is probabilistic, robust error handling is essential.
Handling JSON Parse Failures
Because prompt-based JSON generation is a best-effort technique, there is always a real possibility that the model might return something that is not valid JSON — for example, it might add extra text, use incorrect field names, or include a syntax error. This is not a rare edge case; it is an inherent limitation of relying on instruction-following rather than enforced structure.
By wrapping JSON.parse() in a try/catch block, you can handle failures gracefully. If the model's output is not valid JSON, JSON.parse() will throw an error that you can catch instead of allowing your program to crash. Even when parsing succeeds, result.answer could be missing or have the wrong type. The optional type assertion does not catch either case at runtime. This exercise handles JSON syntax errors; validating the value would require an explicit runtime check, while the next unit uses a strict tool schema to constrain model-generated arguments.
In a production system, you would typically perform actions beyond simply printing an error message — you might log the failure for debugging, retry the request with a modified prompt, or escalate the issue to a human operator. This defensive coding approach is fundamental to working with probabilistic structured outputs.
Summary & Moving to Practice
You have now seen a complete pattern for prompting LLMs to return structured JSON outputs in TypeScript. The flow is:
client.responses.create(...)— send the request with your schema-describingsystem promptin theinstructionsfieldresponse.output— iterate over the output items, find the"message"item, and read its"output_text"content as a plain stringJSON.parse(...)— convert that string into a typedTypeScriptobjecttry/catch— handle the cases where parsing fails or the expected fields are missing
This is a probabilistic approach that works well in many cases but requires robust error handling because the model may not always comply with your instructions. Learning to navigate response.output now will also pay off in later units, where tool calls require inspecting these structured output items directly. In the practice exercises that follow, you will implement this pattern yourself with various schemas and questions, gaining hands-on experience with both successful cases and the error handling required when the model does not return perfectly structured output.
