# 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.

Source: https://voxgig.com/howto/validate-json-model-output-in-typescript

- Audience: agent-builder
- Level: intermediate
- Languages: typescript, javascript
- Verified: 2026-09-14
- Published: 2026-09-14
- Updated: 2026-09-24

## 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](https://platform.openai.com/docs/guides/structured-outputs), turn it on first. Anthropic
documents the same idea as [tool use with a
schema](https://docs.anthropic.com/en/docs/agents-and-tools/tool-use/overview). 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](https://nodejs.org/api/test.html) | 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](https://github.com/voxgig/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](https://valibot.dev/guides/introduction/) | 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](https://zod.dev/) | 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.

```js title="extract.mjs"
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]

```js title="check.mjs"
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.

```bash
node demo.mjs
```

```text output
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.

```bash
node --test check.test.mjs
```

```text output
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

- [Write a validator whose schema looks like the data](/howto/write-a-schema-that-looks-like-the-data)

- [Return validation errors a client can map to a form](/howto/field-level-validation-errors-clients-can-map)

## Last verified

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

[^1]: [RFC 8259](https://www.rfc-editor.org/rfc/rfc8259.html#section-2) 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.

[^2]: The fence is Markdown's, and the word after it has a name.
[CommonMark](https://spec.commonmark.org/0.31.2/#info-string) 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.

[^3]: The page's default is the reverse of the schema language's.
[JSON Schema](https://json-schema.org/draft/2020-12/json-schema-core#section-10.3.2.3) 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](https://developers.openai.com/api/docs/guides/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.