Module 5 · Prompting & Context Engineering ⏱ 17 min

Making Models Return Parseable Structure

By the end of this lesson you will be able to:
  • Turn a model's string into a trusted object through parse, repair, and validate
  • Decide deterministically between accept, repair, and reject for a structured output
  • Avoid the re-prompt loop by repairing with rules or rejecting

You asked for JSON. The model dutifully replied — wrapped in a markdown code fence, with a trailing comma, preceded by the words Here is the JSON:. To a human that is obviously the object you wanted. To JSON.parse it is a syntax error, and your pipeline crashes on the one output that looked perfect. The seam between a language model and the rest of your code is exactly here: the model produces a string, and your program needs a trusted object. Crossing that seam safely is a small, deterministic discipline — parse, repair, validate — and this lesson builds it without ever calling a model.

flowchart LR
  M["model emits<br/>a string"] --> P["parse"]
  P --> V["validate<br/>against schema"]
  V --> O["your code gets<br/>a trusted object"]
  style P fill:#e0900b,color:#fff
  style V fill:#8b5cf6,color:#fff
The seam: the model emits a string; your code needs a validated object.

Three outcomes, not two

When you receive a model's string, exactly one of three things is true. Accept: it is valid JSON and matches the shape you need — use it. Repair: it is almost valid — a code fence to strip, a trailing comma to drop, numbers wrapped in quotes — and a small, rule-based fix turns it into something valid. Reject: it is the wrong shape — a required field missing, an array where you expected an object — and no safe local rule can save it. The discipline is to decide which, deterministically, and never to blur repair into accept.

flowchart TD
  P["parsed JSON"] --> C["check the schema"]
  C --> A["accept"]
  C --> R["repair with a rule"]
  C --> J["reject"]
  style A fill:#166534,color:#fff
  style R fill:#b45309,color:#fff
  style J fill:#b91c1c,color:#fff
Every parsed output is accepted, repaired by a rule, or rejected — never re-rolled.

The two traps inside the string

Two defects account for most parse failures, and both are mechanical. The first is the markdown fence: many models wrap JSON in triple-backtick blocks, sometimes labelled json, because that is how JSON is displayed in their training data. The second is the trailing comma: a list or object with a comma after its last element is friendly to humans but illegal in strict JSON. Both are fixed with a line of text processing — strip the fences, delete a comma that sits before a closing bracket — after which the string parses cleanly. Repair is a rule, not a judgement.

Strip the markdown fence, then the JSON parses. Press Run.
function stripFences(s) {
  return s.replace(/```json/g, '').replace(/```/g, '').trim();
}
const raw = '```json\n{"ok": true}\n```';
console.log(stripFences(raw));
console.log(JSON.parse(stripFences(raw)).ok);
Delete a comma sitting before a closing bracket or brace, then parse. Run it.
function removeTrailingCommas(s) {
  return s.replace(/,(\s*[}\]])/g, '$1');
}
const withCommas = '[1, 2, 3,]';
console.log(removeTrailingCommas(withCommas));
console.log(JSON.parse(removeTrailingCommas(withCommas)));

Validate the shape, then trust it

A string that parses is not yet a string you can trust. A schema — even a tiny one listing the required keys and the maximum length of an array — lets you check the shape before the rest of your code touches it. Validation turns it parsed into it is the thing I asked for. Once an object passes validation, treat it as trusted and stop re-checking; if it fails, reject it rather than guessing what the model meant. The boundary between untrusted model output and trusted program data is the validation step, and it should live in exactly one place.

flowchart LR
  E["model emits<br/>fenced JSON"] --> F["markdown fences<br/>around the payload"]
  F --> N["JSON.parse fails"]
  N --> S["strip the fences first"]
  style N fill:#b91c1c,color:#fff
  style S fill:#166534,color:#fff
A fenced JSON payload fails JSON.parse until the fences are stripped first.
Validate against a tiny schema: required keys and a max array length. Run it.
function validate(obj, schema) {
  for (const key of schema.required || []) {
    if (!(key in obj)) return { ok: false, error: 'missing ' + key };
  }
  if (schema.maxItems !== undefined && Array.isArray(obj.items) && obj.items.length > schema.maxItems) {
    return { ok: false, error: 'too many items' };
  }
  return { ok: true };
}
console.log(validate({ name: 'ada' }, { required: ['name'] }));
console.log(validate({}, { required: ['name'] }));
console.log(validate({ items: [1, 2, 3] }, { maxItems: 2 }));

Determinism: do not ask the model to fix itself

When repair rules are not enough, the temptation is to send the broken string back to the model and ask it to correct itself. Resist it. A second model call is non-deterministic, costs another round-trip, and can loop — the model repairs one defect and introduces another. The deterministic contract is: if your rules can repair it, repair it; if they cannot, reject and let the caller decide. Every model output your program acts on should have passed a fixed, testable pipeline, not a hopeful re-roll.

Exercise

What does this print? stripFences strips code-fence markers from the string and trims. The \n in the input are newline characters.

function stripFences(s) {
  return s.replace(/```json/g, '').replace(/```/g, '').trim();
}
console.log(stripFences('```json\n[1, 2, 3]\n```'));
Exercise

Write removeTrailingCommas(s) that deletes any comma that sits immediately before a closing } or ] (allowing whitespace between the comma and the bracket). It must not change strings that have no such comma. A regex replacement is the cleanest way.

function removeTrailingCommas(s) {
  // delete a comma before a } or ]
}
Exercise

This validate is supposed to REJECT objects missing a required key, but the condition is upside down: it flags objects that HAVE the key and accepts objects that are MISSING it. Flip the one condition so a missing required key returns { ok: false }.

function validate(obj, schema) {
  for (const key of schema.required || []) {
    if (key in obj) return { ok: false, error: 'missing ' + key };
  }
  if (schema.maxItems !== undefined && Array.isArray(obj.items) && obj.items.length > schema.maxItems) {
    return { ok: false, error: 'too many items' };
  }
  return { ok: true };
}
Exercise

Write decide(raw, schema) returning one of three strings. Try to parse raw directly: if it parses and passes the schema, return 'accept'. Otherwise strip fences and trailing commas and try again: if that parses and passes, return 'repair'. In every other case (never parses, or parses but fails the schema), return 'reject'. A helper schemaOk(obj, schema) checking required keys and maxItems is provided in the starter.

function schemaOk(obj, schema) {
  for (const key of schema.required || []) {
    if (!(key in obj)) return false;
  }
  if (schema.maxItems !== undefined && Array.isArray(obj.items) && obj.items.length > schema.maxItems) return false;
  return true;
}

function decide(raw, schema) {
  // direct parse -> 'accept' if schemaOk
  // else strip fences + trailing commas, retry -> 'repair' if schemaOk
  // otherwise -> 'reject'
}
Exercise

A model returns JSON that parses but is missing a required field, and no rule can safely add it. What is the deterministic response?

Recap

  • The model emits a string; your code needs a trusted object. The seam is parse → repair → validate.
  • Three outcomes: accept (valid and well-shaped), repair (fixable by a rule — fences, trailing commas), reject (wrong shape).
  • Strip markdown fences and trailing commas before parsing; both are mechanical fixes.
  • Validate against a minimal schema (required keys, max length) so it parsed becomes it is what I asked for.
  • Never re-ask the model to fix its own output — repair with rules or reject. Determinism is the contract.

Next you will fit all of these payload ingredients into a fixed token budget.

Checkpoint quiz

Why does a model often wrap JSON in triple-backtick fences?

After an object passes your schema validation, how should the rest of your code treat it?

Go deeper — technical resources