A form posts eight fields and the API answers {"error":"Bad Request"}. The client has no way to
say which input is wrong, so it shows one message at the top of the form and the user guesses. Fix one
field, submit again, and the API rejects the next one it happens to check first.
What you get
You will end up with a 422 response that names every failing field by pointer, code, and message. This is for you if your API rejects a body without saying which part of the body it rejected.
Short answer
Return 422 with the media type application/problem+json, and add an errors array to the
problem document. Give each entry a JSON Pointer at the offending value, a stable machine code, and a human
sentence. Report every failing field in one response rather than the first one. A client can then
attach each message to an input without parsing prose.
You will need
Node 22 or later, and an endpoint that takes a JSON body. The envelope is RFC 9457, which defines the problem document and allows extension members such as the errors array below.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| Ajv | The schema is already JSON Schema, shared with your OpenAPI document | Its errors are schema-shaped rather than field-shaped, so mapping them takes real work | You have no JSON Schema and no plan to write one |
| Hand-written checks | Two or three fields, and no dependency budget at all | Each new field is another branch, and the first-failure-wins bug arrives by default | The body has nested objects or more than a few fields |
| shape | You want the schema to read like the accepted body, and every failure reported at once | A smaller community than the alternatives, so fewer worked examples to copy from | Your team already knows one of the other two well |
| Zod | TypeScript services that want inferred types from the schema | A builder syntax to learn, and its issue objects need mapping onto your wire format | The service is plain JavaScript and types buy you nothing |
Voxgig maintains shape. It is one of four options here, not the recommendation.
All four can produce the response below, and they differ in how much translation sits between the validator and the wire. Ajv reports against schema keywords, so a missing property is reported at the parent object and the pointer needs assembling. Zod and shape both report per property. The work you save is the mapping layer, and the work you keep is deciding your own codes.
Put the failures in the envelope
The problem document is the standard part, and the errors array is the extension that makes it usable by a form.
export function validationProblem(errors, { instance, type = 'https://example.com/probs/validation' } = {}) {
return {
type,
title: 'Your request body did not validate',
status: 422,
detail: `${errors.length} field${errors.length === 1 ? '' : 's'} did not validate.`,
instance,
errors,
}
}
export const CONTENT_TYPE = 'application/problem+json'
Three fields per entry, and each one has a different consumer. The pointer is for the client’s form binding. The code is for the client’s logic, so it has to stay stable across releases even when the wording changes. The message is for a person, and nothing should branch on it.
Use 422 rather than 400 when the body parsed and its contents were wrong, and 400 when the bytes were not valid JSON at all. That split tells a client whether to fix a field or fix its serializer.
Codes are the part that repays thought, because they are the part a client writes logic against.
Keep them short, lower case and about the failure rather than about the field: missing,
wrong_type, too_long, not_unique. A code per field, such as invalid_customer_email, produces
a vocabulary that grows with your schema and gives a client nothing to generalize over. The pointer
already says which field failed, so the code only has to say what was wrong with it.
Report every failure at once
A validator that stops at the first failure produces the submit-and-guess loop from the opening paragraph. This one collects them.
// shape reports every failing property in one throw, which is what lets a
// client fix a form in one pass instead of one round trip per field.
const errors = err.props.map((p) => ({
pointer: '/' + p.path.split('.').join('/'),
code: CODES[p.what] ?? p.what,
detail: p.what === 'required'
? `A value of type ${p.type} is required.`
: `Expected type ${p.type}, received ${JSON.stringify(p.value)}.`,
}))
The pointer is built by splitting the validator’s dotted path and joining it with slashes, which is
the JSON Pointer form. A nested field then addresses as
/customer/email rather than as a convention the client has to learn per endpoint.
Check it worked
One body with two different faults, and the document a client receives for it.
node demo.mjs
{
"type": "https://example.com/probs/validation",
"title": "Your request body did not validate",
"status": 422,
"detail": "2 fields did not validate.",
"instance": "/v1/invoices",
"errors": [
{
"pointer": "/amount_cents",
"code": "wrong_type",
"detail": "Expected type number, received \"twelve\"."
},
{
"pointer": "/customer/email",
"code": "missing",
"detail": "A value of type string is required."
}
]
}
Both faults are in one response, and the second one is inside a nested object. A client can bind each entry to an input without knowing anything about the endpoint.
The client side of this is a loop rather than a switch. Read the errors array, resolve each pointer against the object that was submitted, and set the message on the control bound to that path. Anything the client cannot resolve goes to a summary at the top of the form, which covers new fields the client has not been taught about.
node --test validate.test.mjs
1..4
# tests 4
# suites 0
# pass 4
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 131.306036
When it goes wrong
The pointer does not address anything. A pointer is evaluated against the request body, so a validator that renames fields on the way in produces pointers the client cannot resolve. Build the pointer from the path in the body the client sent, before any normalization.
The second failure is a message that leaks. Echoing the rejected value into the detail is helpful for a type error and dangerous for a password field or a card number. Redact values for fields you have marked sensitive, and keep the code and pointer, which are the parts a client needs.
When not to do this
Do not use shape, or any validator, as the only check on a write. A body can be perfectly shaped and still reference a customer that does not exist, and that check belongs where the data lives rather than at the boundary.
Do not put field errors in a 500. A server fault is not the caller’s to fix, and an errors array there invites a client to retry with edits that cannot help.
Do not invent a pointer syntax. JSON Pointer is specified and every language has a parser for it. A dotted path of your own means each client writes a splitter, and gets the escaping wrong on the first field name containing a dot.
Do not let the codes drift. A client branches on them, so renaming one is a breaking change even though nothing in the schema moved. Keep them in one place and treat additions as the only safe edit.
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 and Zod rows describe documented behavior and were not run here.