How-to › Make calls that survive failure

How to return validation errors a client can map to a form#

Answer a bad request with an RFC 9457 problem document carrying one JSON Pointer entry per failing field, so a client can attach each message to an input.

Audience
API producer
Level
intermediate
Topic
Handle and design API errors
Languages
TypeScript and JavaScript
Verified

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
AjvThe schema is already JSON Schema, shared with your OpenAPI documentIts errors are schema-shaped rather than field-shaped, so mapping them takes real workYou have no JSON Schema and no plan to write one
Hand-written checksTwo or three fields, and no dependency budget at allEach new field is another branch, and the first-failure-wins bug arrives by defaultThe body has nested objects or more than a few fields
shapeYou want the schema to read like the accepted body, and every failure reported at onceA smaller community than the alternatives, so fewer worked examples to copy fromYour team already knows one of the other two well
ZodTypeScript services that want inferred types from the schemaA builder syntax to learn, and its issue objects need mapping onto your wire formatThe 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.

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.

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