Your OpenAPI document says amount_cents is an integer. A migration changed the column to numeric,
the driver now returns it as a string, and the document still says integer. Generated SDKs in four
languages deserialize it differently, two of them silently, and the document that everything was
generated from is the only artifact nobody checked.
What you get
You will end up with a validator built from your own document, running over real responses and naming the property that drifted. This is for you if your document and your service can disagree without anything noticing.
Short answer
Read the response schema out of the OpenAPI document, build a validator from it once at startup, and run every response through it in test and staging. Allow properties the document does not describe, because JSON Schema does. Log a failure with the property path rather than throwing, so a drifting field is reported without taking the response away from the caller.
You will need
Node 22 or later, and an OpenAPI 3 document whose responses carry schemas. A document with no response schemas has nothing to validate against, and adding them is the first step rather than a detail.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| Ajv | The document is JSON Schema and you want the whole vocabulary supported | It compiles schemas at runtime, and its error objects need mapping before a person can read them | You want the validator to double as a hand-written schema |
express-openapi-validator | An Express service that wants request and response checking as middleware | It ties validation to one framework, so a worker or a queue handler is left out | The service is not Express, or checks run outside the request path |
| shape | You want one validator style for the document and for hand-written checks | A smaller ecosystem, and the JSON Schema subset you support is yours to grow | The document uses JSON Schema features beyond the common subset |
| Zod | TypeScript services that want the response type inferred from the validator | Converting a document into its builder syntax is code generation rather than a walk | You want to read the document at startup rather than generate from it |
Voxgig maintains shape. It is one of four options here, not the recommendation.
Ajv and the middleware are the ready-made answers, and the walk below is worth writing when you want the same validator over responses that never pass through a web framework. The subset it handles is smaller than Ajv’s on purpose: an unsupported keyword throws when the validator is built rather than passing unnoticed at request time.
Walk the document into a validator
The recursion is short because the schema shapes an API response uses are few.
export function shapeFromSchema(schema, schemas = {}) {
if (schema.$ref) {
const name = schema.$ref.replace('#/components/schemas/', '')
return shapeFromSchema(schemas[name], schemas)
}
if (schema.type === 'array') return [shapeFromSchema(schema.items, schemas)]
if (schema.type === 'object') {
const required = new Set(schema.required ?? [])
const props = Object.fromEntries(
Object.entries(schema.properties ?? {}).map(([key, sub]) => {
const built = shapeFromSchema(sub, schemas)
return [key, required.has(key) ? built : Optional(built)]
}),
)
// JSON Schema allows properties it does not describe unless the document
// says otherwise, so the validator has to allow them too. Closing by
// default would report every field the API adds as a breaking change.
return schema.additionalProperties === false ? props : Open(props)
}
const leaf = LEAF[schema.type]
if (!leaf) throw new Error(`unsupported schema type: ${JSON.stringify(schema.type)}`)
return leaf
}
The comment marks the decision most implementations get backwards. A validator that rejects
undescribed properties reports every additive change as a failure, so the team turns it off within a
week. Follow the document: open by default, closed when it says additionalProperties: false.
The throw on an unknown type is the other deliberate choice. A validator that ignores keywords it does not understand passes responses it never checked, and reports a green run that means nothing.
Build the validator once rather than per request. Reading the document, resolving its references and constructing the checker are startup work, and doing them inside a handler turns a documentation check into a per-response cost nobody budgeted for.
Reference resolution is the part that grows. The preceding walk handles a local $ref into
components.schemas, which covers most single-file documents. A document split across files, or one using
dynamic references in JSON Schema,
needs a resolver. That is the point at which
a maintained parser costs less than
extending the walk yourself, and it is a well-defined job to hand off. Resolve first, validate second, and the validator stays as small as this one.
Check it worked
Four responses against the document, then the same added field under a strict reading of it.
node demo.mjs
as documented valid
amount sent as a string invalid: amount_cents is not number
paid field removed invalid: paid is missing
an added field valid
an added field, strictly invalid: carries a property the document does not describe
Line two is the drift from the opening paragraph, named by property. Lines four and five are the same response read two ways, and the difference between them is one keyword in the document rather than anything in the validator.
Run this over the responses your existing test suite already produces and the first pass is usually noisy. Some of that noise is real drift. Some of it is a document written once, left behind, and never compared with the service since. Both are findings, and separating them is the work the first run buys you. Fix the document where it is wrong, and the service where it is, and the noise stops.
node --test validate.test.mjs
1..6
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 146.133239
When it goes wrong
Validation runs in production and starts costing more than it finds. Every response pays for a walk of its own body, and on a large list endpoint that is real time. Sample instead, validating one response in a hundred. Or run the check only in staging and in the test suite, where the drift shows up just as clearly and nobody is waiting.
The second failure is a validator that throws. A response that fails validation is usually still useful to the caller, and turning a documentation drift into a 500 makes the observability change into an outage. Log the failure with its path and let the response through, and let the log volume decide how urgent the fix is.
When not to do this
Do not use shape, or any validator, as the way you learn what your API returns. The document is the thing under test here, and a validator built from a document nobody maintains only proves that two stale artifacts agree with each other.
Do not validate responses in the client and call it a contract test. A client checking its own copy of the document tests the copy, and the interesting question is what the service actually sent.
Do not extend the walk until it covers all of JSON Schema. At that point you have written Ajv without its test suite. The reason to stop is that a homemade validator fails by staying silent rather than by reporting.
Do not let the document drift because validation is passing. Open validation passes a response with a field the document never mentions, which is how an undocumented field ends up holding somebody’s integration together.
Related how-tos
Last verified
Verified 2026-09-06 against Node 22.22.2 and shape 11.4.1. Both output blocks are what the preceding command printed. The Ajv, Zod and middleware rows describe documented behavior and were not run.