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
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
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.
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);
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
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.
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```'));
Remove the opening fence and the closing fence.
What is left, after trimming the newlines, is the inner text.
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 ]
}
Match a comma, optional whitespace, then a } or ] — capture the bracket.
Replace the whole match with just the captured bracket ($1).
function removeTrailingCommas(s) {
return s.replace(/,(\s*[}\]])/g, '$1');
}
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 };
}
Right now it returns ok:false when the key IS present — the opposite of what you want.
Negate the membership test: return ok:false when the key is NOT in obj.
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 };
}
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'
}
Wrap JSON.parse in try/catch and use a null sentinel to detect failure.
Only call it a repair if the direct parse failed but the cleaned-up one worked.
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) {
let direct = null;
try { direct = JSON.parse(raw); } catch (e) {}
if (direct !== null) return schemaOk(direct, schema) ? 'accept' : 'reject';
const repaired = raw.replace(/```json/gi, '').replace(/```/g, '').replace(/,(\s*[}\]])/g, '$1').trim();
let fixed = null;
try { fixed = JSON.parse(repaired); } catch (e) {}
if (fixed === null) return 'reject';
return schemaOk(fixed, schema) ? 'repair' : 'reject';
}
A model returns JSON that parses but is missing a required field, and no rule can safely add it. What is the deterministic response?
If no deterministic rule can repair the shape, you reject rather than re-roll the model. Re-asking is non-deterministic and can loop; silently accepting untrusted output is worse.
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.