Your extraction step worked for a week, and then a response arrived with a friendly sentence before
the object. JSON.parse threw, the catch block logged and returned null, and the order it was meant
to refund sat in a queue.1 The next failure was subtler: valid JSON, a numeric order_id, and a
lookup that found nothing.
What you get
You will end up with two stages that fail separately. One pulls a JSON value out of text that may be wrapped in prose or a code fence, and one checks the value against a schema you can read. This is for you if a model’s output feeds code rather than a person.
Short answer
Extract before you validate. Try the raw text, then a fenced block, then the widest span between the first bracket and the last. A failure at that stage is a different failure from a bad shape, so report it as one. Then check the parsed value against a schema, and reject keys the schema does not name, because an invented field is the most common way a model gets it wrong.
You will need
Node 22 or later, and a model that returns JSON in a text field. If your provider supports constrained decoding, such as a JSON schema on the response format, turn it on first. Anthropic documents the same idea as tool use with a schema. This page is about the cases that remain, and they do not go away.
Voxgig maintains shape. This page compares it with Zod, Valibot, and a checker you write yourself.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| A hand-written checker | A handful of fields, and you want no dependency in a hot path | Every feature you need written by hand, including the messages | The shapes grow past a page of code |
| gubu | You want the schema to look like the data, and Go alongside TypeScript | A smaller ecosystem than the popular validators, and fewer examples to copy | Your team already knows one of the others |
| Valibot | Bundle size matters, such as an agent running in a browser or a worker | A younger ecosystem, so fewer integrations exist for it | Bundle size is not a constraint you have |
| Zod | Most TypeScript projects, because the types and the integrations are everywhere | A runtime dependency and schemas written in a builder rather than as data | You want the schema to be plain data you can serialize |
The choice turns on whether the schema is data or code. A builder gives you inferred TypeScript types, which is a real benefit at the call site. A schema that is plain data can be stored, sent to a model inside the prompt, and compared between versions. That matters when the thing being validated came from the same schema. Pick the builder for types, and the data form when the schema has a second job.
Extract first, and say which path worked
Each of the three attempts, tried in order, reports how it succeeded.
const fence = /```(?:json)?\s*([\s\S]*?)```/.exec(trimmed)
if (fence) {
const inner = tryParse(fence[1].trim())
if (inner.ok) return { ...inner, via: 'fence' }
}
Record the path. A model that suddenly starts wrapping every answer in a fence is a prompt regression, and the only way to see it is to count how often each path is used.2 A pipeline that silently repairs everything hides the drift until the repair stops working.
Treat truncation as its own outcome. A response cut off at the token limit is incomplete rather than wrong. The fix is a larger limit or a smaller request, and never a looser schema.
Reject the keys you did not ask for
The default matters more here than in ordinary input validation.3
if (extraKeys === 'reject') {
for (const key of Object.keys(value)) {
if (!(key in schema)) problems.push(`${key}: not in the schema`)
}
}
Models add fields. A reasoning key, a notes key, an explanation nobody asked for: each is
harmless until something downstream iterates the object or writes it to a table. Rejecting by
default turns that into a caught failure on the first response rather than a surprise in a report
three months later.
Allow extra keys deliberately, per call site, when you have a reason. That reads as a decision rather than as an oversight.
Check it worked
Run all six shapes through both stages.
node demo.mjs
clean direct accepted
fenced fence accepted
chatty span accepted
wrongType direct order_id: expected string, got number; confidence: expected number, got string
extraKey direct reasoning: not in the schema
truncated none no JSON value in the output
The fourth row is the one that would have reached production. It parses, it has every key the schema
names, and two of the values are the wrong type. A pipeline that only calls JSON.parse accepts it,
and the failure lands in whatever looks up an order by a number that was meant to be a string.
node --test check.test.mjs
1..6
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 150.460729
When it goes wrong
Everything fails after a model upgrade. The new version is more conversational and the extractor was written for one shape. Keep all three paths, and alert on the mix rather than on the failures.
A retry loop spins on the same bad output. Retrying an identical request gets a similar answer. Send the validation problems back to the model as the next message, and cap the attempts at two.
Numbers arrive as strings. Some providers stringify everything under a schema. Decide once whether to coerce, do it in one place, and never in the validator’s default path.
Confidence scores look precise and mean nothing. A model returning 0.91 is producing a token, not
a measurement. Treat it as a hint for routing, and never as a threshold for an automated write.
When not to do this
Do not validate and then discard the problems. The list of what was wrong is the most useful thing you have for improving the prompt, and throwing it away leaves you tuning by anecdote.
Do not repair output silently in production. A pipeline that strips prose, fixes quotes, and closes brackets will accept something eventually that no human would have. Log every repair, and treat a rising repair rate as the failure it is.
Do not let the schema drift from the prompt. If the prompt names four fields and the schema names five, the model is being asked for one thing and judged by another. Generate the prompt fragment from the schema, and the two cannot disagree.
Do not adopt shape, or any other validator, to check one field. A dependency earns its place when the shapes are numerous or nested, and a single required string is three lines of code. The Go port is the reason to choose shape over the TypeScript-only options, and a project with no Go in it does not collect that benefit.
Related how-tos
Last verified
Verified 2026-09-14 against Node 22.22.2. Both output blocks are what the preceding command printed.
Footnotes
-
RFC 8259 defines a JSON text as
ws value ws, andwsas any number of four code points: space, horizontal tab, line feed and carriage return. The grammar calls that whitespace insignificant, and it is the only thing allowed on either side of the value. A friendly sentence is not on the list, and neither is a non-breaking space, so a parser that keeps to the grammar stops at the first letter. ↩︎ Back to text -
The fence is Markdown’s, and the word after it has a name. CommonMark calls it the info string, trims it of spaces and tabs, and says the first word is typically used to specify the language of the code sample. The same paragraph declines to mandate any particular treatment of it. The regular expression on this page makes
jsonoptional, which is as much as the specification commits to. A fence may also be three tildes, and backticks and tildes cannot be mixed, a rule the specification states without saying who tried. ↩︎ Back to text -
The page’s default is the reverse of the schema language’s. JSON Schema applies
additionalPropertiesonly to names thatpropertiesdoes not list, and omitting the keyword has the same assertion behavior as an empty schema, which accepts anything. OpenAI’s Structured Outputs supports a subset of that language and reverses the default by decree:additionalProperties: falsemust always be set in objects, and all fields must be required. The vendor that generates the output and the page that checks it arrive at the same default from opposite ends. ↩︎ Back to text