How-to › Use AI to do the integration

How to validate JSON model output before you use it#

Get a typed value out of a model response that is only mostly JSON, and refuse the responses that are the wrong shape instead of letting them into your code.

Audience
Agent builder
Level
intermediate
Topic
Constrain model output to a schema
Languages
TypeScript and JavaScript
Verified

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
A hand-written checkerA handful of fields, and you want no dependency in a hot pathEvery feature you need written by hand, including the messagesThe shapes grow past a page of code
gubuYou want the schema to look like the data, and Go alongside TypeScriptA smaller ecosystem than the popular validators, and fewer examples to copyYour team already knows one of the others
ValibotBundle size matters, such as an agent running in a browser or a workerA younger ecosystem, so fewer integrations exist for itBundle size is not a constraint you have
ZodMost TypeScript projects, because the types and the integrations are everywhereA runtime dependency and schemas written in a builder rather than as dataYou 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.

Last verified

Verified 2026-09-14 against Node 22.22.2. Both output blocks are what the preceding command printed.

Footnotes

  1. RFC 8259 defines a JSON text as ws value ws, and ws as 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

  2. 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 json optional, 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

  3. The page’s default is the reverse of the schema language’s. JSON Schema applies additionalProperties only to names that properties does 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: false must 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

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.