JSON Schema Custom Error Messages: Ajv errorMessage Keyword
JSON Schema validation errors are technical by default — Ajv's built-in messages like "must be string" or "must match pattern" are accurate but not user-friendly. The ajv-errors npm package adds an errorMessage keyword to JSON Schema that replaces validation errors with custom strings, objects, or per-keyword messages. A single errorMessage: "Email address is required" overrides all errors for a property, while errorMessage: {"type": "Must be a string", "minLength": "Must be at least 8 characters"} maps each JSON Schema keyword to a specific message. The ajv-errors package works with Ajv 8.x and supports 3 message formats: string (replaces all errors), object (per-keyword), and an array for multiple localized messages. For API responses, format errors as a flat array of {field, message} objects using a custom error formatter. Zod validation offers a similar .message() API for TypeScript-first validation without JSON Schema. This guide covers ajv-errors setup, per-field and per-keyword messages, nested object errors, and formatting errors for REST API JSON responses.
Need to inspect a validation error payload or check your JSON Schema syntax? Jsonic's validator handles it instantly.
Open JSON Schema ValidatorSetup: ajv-errors with Ajv 8
Install ajv and ajv-errors together — they are separate packages and must be version-compatible. Ajv 8.x is required; the older Ajv 6.x API differs significantly and is not supported by current ajv-errors releases. The setup takes 4 lines of code and must include allErrors: true or custom messages will be silently incomplete.
npm install ajv ajv-errorsimport Ajv from "ajv"
import addErrors from "ajv-errors"
// allErrors: true is REQUIRED — without it, Ajv stops at the first error
// and ajv-errors cannot replace all relevant messages
const ajv = new Ajv({ allErrors: true })
addErrors(ajv)
const schema = {
type: "object",
properties: {
email: {
type: "string",
format: "email",
errorMessage: "Must be a valid email address"
},
age: {
type: "integer",
minimum: 0,
maximum: 120,
errorMessage: {
type: "Age must be a number",
minimum: "Age cannot be negative",
maximum: "Age must be 120 or less"
}
}
},
required: ["email", "age"],
additionalProperties: false
}
const validate = ajv.compile(schema)
const data = { email: "not-an-email", age: -5 }
const valid = validate(data)
if (!valid) {
console.log(validate.errors)
// [
// { instancePath: '/email', message: 'Must be a valid email address', ... },
// { instancePath: '/age', message: 'Age cannot be negative', ... }
// ]
}Without allErrors: true, Ajv stops at the first error — you'll only get 1 error message per validation call. With it, Ajv collects all errors in a single pass across all properties and all keywords, so ajv-errors can match and replace each one. Compile the schema once with ajv.compile(schema) and reuse the returned validate function — compilation is expensive, calling the compiled function is cheap. See Ajv validation for a full introduction to compiling and running schemas.
String errorMessage: Replace All Errors
The simplest form of errorMessage is a plain string: "errorMessage": "Must be a valid email address" on a property replaces all validation errors (type, format, pattern) for that property with that single string. This is the right choice when any failure means the same thing to the user — for example, an email field where whether the problem is wrong type, wrong format, or wrong pattern, the answer is always "enter a valid email."
const schema = {
type: "object",
properties: {
email: {
type: "string",
format: "email",
pattern: "^[^@]+@[^@]+\.[^@]+$",
errorMessage: "Must be a valid email address"
},
username: {
type: "string",
minLength: 3,
maxLength: 30,
pattern: "^[a-zA-Z0-9_]+$",
errorMessage: "Username must be 3–30 characters, letters, numbers, and underscores only"
}
},
required: ["email", "username"]
}
// If email fails type, format, OR pattern — always the same message:
// { instancePath: '/email', message: 'Must be a valid email address' }
// Debug: the original Ajv errors are in err.params.errors
validate.errors?.forEach(err => {
if (err.keyword === 'errorMessage') {
console.log('Custom message:', err.message)
console.log('Original errors:', err.params.errors)
// params.errors contains the underlying Ajv errors that triggered this
}
})The string form works well for 3 types of fields: email addresses, phone numbers, and URLs — where format is binary (valid or not) and there is no meaningful distinction between failure modes. The original Ajv error is still accessible via err.params.errors for server-side logging and debugging. The ajv-errors plugin emits a single error object with keyword: "errorMessage" and suppresses the underlying Ajv errors from the final array, so clients only see 1 clean message per field. Use JSON Schema guide to understand which keywords you can target with per-keyword messages.
Object errorMessage: Per-Keyword Messages
When a field has multiple validation rules and each failure mode deserves a different message, use the object form of errorMessage. Each key is a JSON Schema keyword, and its value is the message to show when that keyword fails. Only the triggered keyword's message appears — if minLength fails, pattern's message does not show. This keeps feedback precise and actionable.
const passwordSchema = {
type: "object",
properties: {
password: {
type: "string",
minLength: 8,
maxLength: 64,
pattern: "^(?=.*[a-zA-Z])(?=.*[0-9])",
errorMessage: {
type: "Password must be a string",
minLength: "Password must be at least 8 characters",
maxLength: "Password must be 64 characters or fewer",
pattern: "Password must contain at least 1 letter and 1 number"
}
}
},
required: ["password"]
}
// Input: { password: "abc" }
// validate.errors → [
// {
// instancePath: '/password',
// keyword: 'errorMessage',
// message: 'Password must be at least 8 characters',
// params: { errors: [ /* original minLength error */ ] }
// }
// ]
// Input: { password: "abcdefgh" } (8 chars, no digit)
// validate.errors → [
// {
// instancePath: '/password',
// keyword: 'errorMessage',
// message: 'Password must contain at least 1 letter and 1 number'
// }
// ]The object form maps each of the 4 targeted keywords to a specific message. When both minLength and pattern fail simultaneously (e.g., a 3-character all-letter password), both messages appear as separate error objects — one per keyword. This gives users complete feedback in a single validation pass. The required keyword is handled at the object level, not the property level — to customize missing-field messages, add errorMessage to the object schema's required array or use the errorMessage: { required: { fieldName: "Field is required" } } form supported by ajv-errors.
Nested Objects and Arrays
For nested objects, place errorMessage at the nested schema level — each sub-schema is independent. For arrays, errorMessage can override minItems, maxItems, and contains keyword errors. The instancePath on nested errors reflects the full JSON Pointer path, making it straightforward to map errors back to specific fields in a form.
const schema = {
type: "object",
properties: {
address: {
type: "object",
properties: {
street: {
type: "string",
minLength: 1,
errorMessage: "Street address is required"
},
zipCode: {
type: "string",
pattern: "^[0-9]{5}(-[0-9]{4})?$",
errorMessage: {
type: "ZIP code must be a string",
pattern: "ZIP code must be 5 digits (12345) or 9 digits (12345-6789)"
}
},
country: {
type: "string",
enum: ["US", "CA", "GB"],
errorMessage: {
type: "Country must be a string",
enum: "Country must be US, CA, or GB"
}
}
},
required: ["street", "zipCode", "country"]
},
tags: {
type: "array",
items: { type: "string" },
minItems: 1,
maxItems: 5,
errorMessage: {
minItems: "At least 1 tag is required",
maxItems: "No more than 5 tags are allowed",
type: "Tags must be an array"
}
}
}
}
// Input: { address: { street: "", zipCode: "ABCDE", country: "FR" }, tags: [] }
// validate.errors → [
// { instancePath: '/address/street', message: 'Street address is required' },
// { instancePath: '/address/zipCode', message: 'ZIP code must be 5 digits (12345) or 9 digits (12345-6789)' },
// { instancePath: '/address/country', message: 'Country must be US, CA, or GB' },
// { instancePath: '/tags', message: 'At least 1 tag is required' }
// ]Nested errors have instancePath values like /address/zipCode — 3 segments for a 2-level nesting. Array item errors (when items has a schema with errorMessage) appear as /tags/0, /tags/1, etc. — the numeric index is the array position. Deep nesting beyond 3 levels is rare in API payloads; if your schema has more, consider flattening the input shape to simplify validation error reporting.
Formatting Errors for API Responses
Transform ajv.errors into a user-friendly flat array for API responses. The key transformation is instancePath (a JSON Pointer like /address/zipCode) to a dot-notation field name (address.zipCode). A complete formatter function handles 3 cases: regular property errors, required property errors (where the field name is in params.missingProperty), and root-level errors (empty instancePath).
import Ajv from "ajv"
import addErrors from "ajv-errors"
const ajv = new Ajv({ allErrors: true })
addErrors(ajv)
// Formatter: converts Ajv errors to { field, message } pairs
function formatErrors(errors: ReturnType<typeof validate>['errors']) {
if (!errors) return []
return errors
.filter(err => err.keyword === 'errorMessage' || err.keyword === 'required')
.map(err => {
// required errors: field name is in params.missingProperty
if (err.keyword === 'required') {
const parent = err.instancePath ? err.instancePath.slice(1).replace(/\//g, '.') + '.' : ''
return {
field: parent + (err.params.missingProperty as string),
message: `${err.params.missingProperty} is required`
}
}
// errorMessage errors: instancePath is the field path
return {
field: err.instancePath.slice(1).replace(/\//g, '.'),
message: err.message ?? 'Invalid value'
}
})
}
// Express / Node.js route example
app.post('/users', (req, res) => {
const valid = validate(req.body)
if (!valid) {
return res.status(400).json({
errors: formatErrors(validate.errors)
})
}
// proceed with valid data...
res.status(201).json({ id: 1, ...req.body })
})
// Example output for POST /users with bad body:
// HTTP 400 Bad Request
// Content-Type: application/json
// {
// "errors": [
// { "field": "email", "message": "Must be a valid email address" },
// { "field": "password", "message": "Password must be at least 8 characters" },
// { "field": "address.zipCode", "message": "ZIP code must be 5 digits" }
// ]
// }The instancePath slice removes the leading / and the replace converts path separators: /address/zipCode becomes address.zipCode. Return the response as 400 Bad Request with Content-Type: application/json. Always return all errors in one response — this is why allErrors: true is required. A flat errors array with {field, message} pairs is compatible with React Hook Form, Formik, and most frontend validation display libraries that accept server-side errors. See REST API JSON responses for guidance on error envelope design and HTTP status code selection.
Key definitions
- errorMessage keyword
- A non-standard JSON Schema keyword added by the
ajv-errorsplugin that allows schema authors to specify custom human-readable error messages for a property, either as a single string (replacing all errors) or as an object mapping JSON Schema keywords to specific messages. - instancePath
- A JSON Pointer string in each Ajv error object that identifies the location in the validated data where the error occurred — for example,
/address/cityfor a nested field or/emailfor a top-level field. An empty string means the error applies to the root object. - allErrors option
- An Ajv constructor option (
new Ajv({allErrors: true}) that forces Ajv to continue validation after the first error and collect all failing keywords across all properties, rather than stopping at the first failure. Required forajv-errorsto function correctly. - ajv-errors
- An npm package that extends Ajv 8.x by registering the
errorMessagekeyword as a custom keyword via Ajv's plugin API. It post-processes Ajv's internal error array after validation completes, replaces matched errors with custom messages, and removes the original technical errors from the output. - JSON Pointer
- A string syntax defined in RFC 6901 that identifies a specific value within a JSON document using a sequence of slash-separated tokens — for example,
/properties/email/minLength. Ajv uses JSON Pointers in bothinstancePath(location in the data) andschemaPath(location in the schema). - params.errors
- A property on each
errorMessageerror object that contains the array of original Ajv errors that were suppressed and replaced by the custom message. Useful for server-side logging to understand the underlying validation failure without exposing technical details to API clients.
Frequently asked questions
How do I add custom error messages to JSON Schema validation?
Use the ajv-errors package with Ajv 8. Add "errorMessage" as a keyword in your schema: a string replaces all errors for that property, or an object maps JSON Schema keywords (type, minLength, pattern) to specific messages. Install with npm install ajv ajv-errors and initialize with allErrors: true. Call addErrors(ajv) after creating the Ajv instance to register the errorMessage keyword. Then compile your schema with ajv.compile(schema) and call validate(data). If validation fails, validate.errors contains your custom messages instead of Ajv's default technical strings. See also Ajv validation for a full introduction to compiling and using schemas.
What is the difference between errorMessage string and object format?
String format: "errorMessage": "Invalid value" replaces all errors for the property with one message, regardless of which keyword failed. Object format: "errorMessage": {"minLength": "Too short", "pattern": "Invalid format"} shows the specific message only when that keyword fails — different messages appear for different violations. Use string format for simple fields where any failure means the same thing to the user. Use object format for fields with multiple validation rules (like a password field with type, minLength, and pattern) so users get precise guidance on exactly what needs to change. When multiple keywords fail simultaneously, multiple messages appear — one per failing keyword.
Why does Ajv need allErrors: true for custom errors?
Without allErrors: true, Ajv stops at the first failing keyword and returns only 1 error. With it, Ajv collects all errors in a single pass, so ajv-errors can match and replace each one with the corresponding custom message. The ajv-errors plugin silently produces incomplete results without this option — you may see only some custom messages or fall back to raw Ajv errors. Always set new Ajv({allErrors: true}) when using ajv-errors. The performance cost of allErrors is negligible for typical API payloads and is essential for giving users complete validation feedback in a single network round-trip.
How do I get the field name from an Ajv error?
Use err.instancePath. It is a JSON Pointer string like /address/city. To convert to a dot-notation field name: err.instancePath.replace(/^\//, '').replace(/\//g, '.') produces address.city. Root-level fields appear as /fieldName — slicing the leading slash gives the plain field name. For required property errors, instancePath points to the parent object, not the missing field; use err.params.missingProperty instead. Combine both cases in a single formatter function to handle all error types. See the error formatting section above for a complete implementation.
Can I use custom error messages with Zod instead of Ajv?
Yes. Zod validation uses .message() on type methods: z.string().min(8, { message: "At least 8 characters" }) or z.object({ email: z.string().email("Invalid email address") }). safeParse(data).error.issues returns an array with path (array of strings/numbers) and message (your custom string). Unlike ajv-errors, Zod custom messages are defined in TypeScript code rather than JSON Schema, making them more type-safe and refactor-friendly. Zod does not require a separate plugin — custom messages are a first-class feature of every type method. The tradeoff: Zod schemas are JavaScript/TypeScript only and cannot be shared as JSON Schema with other tools.
How do I return validation errors in an API response?
Use HTTP 422 Unprocessable Entity or 400 Bad Request. Response body: {"errors": [{"field": "email", "message": "Must be a valid email"}, {"field": "password", "message": "Too short"}]}. Map ajv.errors to {field, message} pairs using instancePath and the custom message property. Always return all errors in one response — this requires allErrors: true on the Ajv instance. Set Content-Type: application/json and include a top-level "errors" array (not a single "error" string) so clients can display field-level feedback. The flat array format is compatible with most frontend form libraries that expect {field, message} pairs.
Ready to validate your JSON Schema?
Use Jsonic's JSON Schema Validator to test your schema and see exactly which errors Ajv produces before you add custom messages. You can also use the JSON Formatter to pretty-print and inspect validation error payloads from your API.
Open JSON Schema Validator