From fa5b7cec742c5f25eafa3ed36853f2ae7ff1a231 Mon Sep 17 00:00:00 2001 From: dhairya05 Date: Wed, 4 Nov 2026 21:28:05 +0530 Subject: [PATCH 1/5] feat: add a const validation error that names the expected value ajv reports const failures as "must be equal to constant" with the expected value in params.allowedValue, which was dropped. Mirrors the enum error, which already appends its allowed values. --- src/types.ts | 1 + src/validation-errors/const.ts | 41 ++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 src/validation-errors/const.ts diff --git a/src/types.ts b/src/types.ts index 0085404a..808affec 100644 --- a/src/types.ts +++ b/src/types.ts @@ -14,6 +14,7 @@ export interface AjvError> { } export type EnumParams = { allowedValues: unknown[] }; +export type ConstParams = { allowedValue: unknown }; export type RequiredParams = { missingProperty: string }; export type AdditionalPropertiesParams = { additionalProperty: string }; // ajv-errors replaces the errors it customizes with one `errorMessage` error. diff --git a/src/validation-errors/const.ts b/src/validation-errors/const.ts new file mode 100644 index 00000000..2b9b56d9 --- /dev/null +++ b/src/validation-errors/const.ts @@ -0,0 +1,41 @@ +import { styleText } from 'node:util'; +import type { IOutputError } from '../index'; +import type { ConstParams } from '../types'; +import BaseValidationError from './base'; + +// The allowed value is printed as JSON so a string is quoted and tells the reader +// that the schema expects exactly that text. +const toText = (value: unknown): string => JSON.stringify(value) ?? String(value); + +export default class ConstValidationError extends BaseValidationError { + print(): string[] { + const { + message, + params: { allowedValue }, + } = this.options; + + const output = [ + styleText('red', styleText('bold', 'CONST') + ' ' + message), + styleText('red', `(${toText(allowedValue)})\n`), + ]; + + return output.concat( + this.getCodeFrame( + '👈🏽 Should be equal to ' + styleText('magentaBright', toText(allowedValue)) + ) + ); + } + + getError(): IOutputError { + const { + message, + params: { allowedValue }, + } = this.options; + + return { + ...this.getLocation(), + error: this.withDecoratedPath(`${message}: ${toText(allowedValue)}`), + path: this.instancePath, + }; + } +} From c828153fc70b1a89ad3372738788321f2da3fde9 Mon Sep 17 00:00:00 2001 From: dhairya05 Date: Wed, 4 Nov 2026 21:28:15 +0530 Subject: [PATCH 2/5] feat: route const errors to the new error class --- src/helpers.ts | 4 ++++ src/validation-errors/index.ts | 1 + 2 files changed, 5 insertions(+) diff --git a/src/helpers.ts b/src/helpers.ts index 121b14dc..817bf08c 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -1,6 +1,7 @@ import type { AdditionalPropertiesParams, AjvError, + ConstParams, ErrorContext, ErrorNode, RequiredParams, @@ -20,6 +21,7 @@ import { AdditionalPropValidationError, RequiredValidationError, EnumValidationError, + ConstValidationError, DefaultValidationError, } from './validation-errors/index'; import type BaseValidationError from './validation-errors/base'; @@ -179,6 +181,8 @@ export function createErrorInstances( return ret.concat( new RequiredValidationError(error as AjvError, options) ); + case 'const': + return ret.concat(new ConstValidationError(error as AjvError, options)); default: return ret.concat(new DefaultValidationError(error, options)); } diff --git a/src/validation-errors/index.ts b/src/validation-errors/index.ts index 39ff94b8..4ba3cb8f 100644 --- a/src/validation-errors/index.ts +++ b/src/validation-errors/index.ts @@ -1,4 +1,5 @@ export { default as RequiredValidationError } from './required'; export { default as AdditionalPropValidationError } from './additional-prop'; export { default as EnumValidationError } from './enum'; +export { default as ConstValidationError } from './const'; export { default as DefaultValidationError } from './default'; From 78b141968de7217343a6490457153a715bb52649 Mon Sep 17 00:00:00 2001 From: dhairya05 Date: Wed, 4 Nov 2026 21:29:23 +0530 Subject: [PATCH 3/5] test: cover const errors naming the expected value Covers the js and cli formats, and non-string constants. --- src/__tests__/index.ts | 57 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/src/__tests__/index.ts b/src/__tests__/index.ts index 71a6aafa..6efb4007 100644 --- a/src/__tests__/index.ts +++ b/src/__tests__/index.ts @@ -157,6 +157,63 @@ describe('Main', () => { ]); }); + describe('const', () => { + const getErrors = (schema: SchemaObject, data: unknown) => { + const validate = new Ajv({ allErrors: true }).compile(schema); + expect(validate(data)).toBeFalsy(); + return betterAjvErrors(schema, data, validate.errors, { format: 'js', indent: 2 }); + }; + + it('names the expected value instead of only saying "constant"', () => { + const schema = { + type: 'object', + properties: { node: { const: '>=18.12.0' } }, + }; + expect(getErrors(schema, { node: '>=18.12.1' })).toEqual([ + { + start: { line: 2, column: 11, offset: 12 }, + end: { line: 2, column: 22, offset: 23 }, + error: '/node must be equal to constant: ">=18.12.0"', + path: '/node', + }, + ]); + }); + + it('prints non-string constants as JSON', () => { + const schema = { + type: 'object', + properties: { + port: { const: 8080 }, + nothing: { const: null }, + flag: { const: false }, + }, + }; + const errors = getErrors(schema, { port: 80, nothing: 'x', flag: true }); + expect(errors.map(error => error.error)).toEqual([ + '/port must be equal to constant: 8080', + '/nothing must be equal to constant: null', + '/flag must be equal to constant: false', + ]); + }); + + it('includes the expected value in the cli output', () => { + const schema = { + type: 'object', + properties: { node: { const: '>=18.12.0' } }, + }; + const data = { node: '>=18.12.1' }; + const validate = new Ajv().compile(schema); + expect(validate(data)).toBeFalsy(); + + const output = stripVTControlCharacters( + betterAjvErrors(schema, data, validate.errors, { indent: 2 }) + ); + expect(output).toContain('CONST must be equal to constant'); + expect(output).toContain('(">=18.12.0")'); + expect(output).toContain('Should be equal to ">=18.12.0"'); + }); + }); + describe('property names with characters outside word characters and -', () => { const getErrors = (schema: SchemaObject, data: unknown) => { const validate = new Ajv({ allErrors: true }).compile(schema); From 14725252ddfb354b1a7963ad739383319fdd862a Mon Sep 17 00:00:00 2001 From: dhairya05 Date: Wed, 4 Nov 2026 21:56:11 +0530 Subject: [PATCH 4/5] chore: add a changeset --- .changeset/const-expected-value.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/const-expected-value.md diff --git a/.changeset/const-expected-value.md b/.changeset/const-expected-value.md new file mode 100644 index 00000000..5b1ba6d8 --- /dev/null +++ b/.changeset/const-expected-value.md @@ -0,0 +1,5 @@ +--- +"better-ajv-errors": minor +--- + +Name the expected value in `const` errors. ajv reports a `const` failure as `must be equal to constant` and carries the expected value in `params.allowedValue`, which was dropped because `const` had no handler of its own. A `const` error now reads `/node must be equal to constant: ">=18.12.0"`, with the value printed as JSON so strings stay quoted and `null`, `false` and numbers are unambiguous. The `cli` format gains the value in its header and in the codeframe pointer. From 177154d3059bc38790a291190ede1dc967f5b847 Mon Sep 17 00:00:00 2001 From: dhairya05 Date: Sat, 7 Nov 2026 20:55:45 +0530 Subject: [PATCH 5/5] refactor: handle const as a single-value enum Dropping ConstValidationError and rewriting the ajv error to an enum with one allowed value means const goes through the enum path instead of around it, so oneOf branches that don't match no longer get reported and sibling branches merge into one error. Also gets the did-you-mean suggestion for free, and drops the JSON.stringify that threw on circular values. --- .changeset/const-expected-value.md | 2 +- src/__tests__/index.ts | 93 +++++++++++++++++++++++++----- src/helpers.ts | 21 +++++-- src/validation-errors/const.ts | 41 ------------- src/validation-errors/index.ts | 1 - 5 files changed, 95 insertions(+), 63 deletions(-) delete mode 100644 src/validation-errors/const.ts diff --git a/.changeset/const-expected-value.md b/.changeset/const-expected-value.md index 5b1ba6d8..9616173b 100644 --- a/.changeset/const-expected-value.md +++ b/.changeset/const-expected-value.md @@ -2,4 +2,4 @@ "better-ajv-errors": minor --- -Name the expected value in `const` errors. ajv reports a `const` failure as `must be equal to constant` and carries the expected value in `params.allowedValue`, which was dropped because `const` had no handler of its own. A `const` error now reads `/node must be equal to constant: ">=18.12.0"`, with the value printed as JSON so strings stay quoted and `null`, `false` and numbers are unambiguous. The `cli` format gains the value in its header and in the codeframe pointer. +Name the expected value in `const` errors. ajv reports a `const` failure as `must be equal to constant` and carries the expected value in `params.allowedValue`, which was dropped because `const` had no handler of its own. A `const` is an `enum` with one allowed value, so it is now rewritten as one and goes through the existing `enum` handling: the error reads `/node must be equal to constant: >=18.12.0`, redundant branches of a `oneOf` are filtered out, sibling branches merge into a single error, and a close value gets a "Did you mean" suggestion. diff --git a/src/__tests__/index.ts b/src/__tests__/index.ts index 6efb4007..ae11b83a 100644 --- a/src/__tests__/index.ts +++ b/src/__tests__/index.ts @@ -173,27 +173,88 @@ describe('Main', () => { { start: { line: 2, column: 11, offset: 12 }, end: { line: 2, column: 22, offset: 23 }, - error: '/node must be equal to constant: ">=18.12.0"', + error: '/node must be equal to constant: >=18.12.0', path: '/node', + suggestion: 'Did you mean >=18.12.0?', }, ]); }); - it('prints non-string constants as JSON', () => { + it('names non-string constants', () => { + const only = (constant: unknown, value: unknown) => { + const schema = { type: 'object', properties: { field: { const: constant } } }; + return getErrors(schema, { field: value }).map(error => error.error); + }; + + expect(only(8080, 80)).toEqual(['/field must be equal to constant: 8080']); + expect(only(false, true)).toEqual(['/field must be equal to constant: false']); + // A null constant renders the way `enum: [null]` already renders. + expect(only(null, 'x')).toEqual(['/field must be equal to constant: ']); + }); + + it('does not blame the discriminator of a branch that did not match', () => { + // `kind` is already 'a', so the reader should be told about `x`, not + // told to change the one value that is right. const schema = { type: 'object', - properties: { - port: { const: 8080 }, - nothing: { const: null }, - flag: { const: false }, - }, + oneOf: [ + { properties: { kind: { const: 'a' }, x: { type: 'number' } } }, + { properties: { kind: { const: 'b' }, y: { type: 'string' } } }, + ], }; - const errors = getErrors(schema, { port: 80, nothing: 'x', flag: true }); - expect(errors.map(error => error.error)).toEqual([ - '/port must be equal to constant: 8080', - '/nothing must be equal to constant: null', - '/flag must be equal to constant: false', - ]); + const errors = getErrors(schema, { kind: 'a', x: 'nope' }).map(error => error.error); + expect(errors).toContain('/x: type must be number'); + expect(errors.some(error => error.startsWith('/kind'))).toBe(false); + }); + + it('reports the same errors as the equivalent single-value enum', () => { + // A `const` is an `enum` of one, so the two should be filtered and + // merged identically. Only ajv's own wording differs. + const build = (keyword: 'const' | 'enum', value: string) => + keyword === 'const' ? { const: value } : { enum: [value] }; + + for (const [name, makeSchema, data] of [ + [ + 'oneOf', + (keyword: 'const' | 'enum') => ({ + type: 'object', + oneOf: [ + { properties: { kind: build(keyword, 'a'), x: { type: 'number' } } }, + { properties: { kind: build(keyword, 'b'), y: { type: 'string' } } }, + ], + }), + { kind: 'a', x: 'nope' }, + ], + [ + 'anyOf', + (keyword: 'const' | 'enum') => ({ + type: 'object', + anyOf: [ + { properties: { kind: build(keyword, 'a') } }, + { properties: { kind: build(keyword, 'b') } }, + ], + }), + { kind: 'c' }, + ], + ] as const) { + const fromConst = getErrors(makeSchema('const'), data); + const fromEnum = getErrors(makeSchema('enum'), data); + expect( + fromConst.map(error => error.path), + name + ).toEqual(fromEnum.map(error => error.path)); + expect(fromConst.length, name).toBe(fromEnum.length); + } + }); + + it('does not throw on a constant that cannot be serialised', () => { + const circular: Record = { name: 'loop' }; + circular.self = circular; + const schema = { + type: 'object', + properties: { node: { const: circular } }, + }; + expect(() => getErrors(schema, { node: 'nope' })).not.toThrow(); }); it('includes the expected value in the cli output', () => { @@ -208,9 +269,9 @@ describe('Main', () => { const output = stripVTControlCharacters( betterAjvErrors(schema, data, validate.errors, { indent: 2 }) ); - expect(output).toContain('CONST must be equal to constant'); - expect(output).toContain('(">=18.12.0")'); - expect(output).toContain('Should be equal to ">=18.12.0"'); + expect(output).toContain('must be equal to constant'); + expect(output).toContain('(>=18.12.0)'); + expect(output).toContain('Did you mean >=18.12.0 here?'); }); }); diff --git a/src/helpers.ts b/src/helpers.ts index 817bf08c..e263aa99 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -2,6 +2,7 @@ import type { AdditionalPropertiesParams, AjvError, ConstParams, + EnumParams, ErrorContext, ErrorNode, RequiredParams, @@ -21,7 +22,6 @@ import { AdditionalPropValidationError, RequiredValidationError, EnumValidationError, - ConstValidationError, DefaultValidationError, } from './validation-errors/index'; import type BaseValidationError from './validation-errors/base'; @@ -181,8 +181,6 @@ export function createErrorInstances( return ret.concat( new RequiredValidationError(error as AjvError, options) ); - case 'const': - return ret.concat(new ConstValidationError(error as AjvError, options)); default: return ret.concat(new DefaultValidationError(error, options)); } @@ -191,11 +189,26 @@ export function createErrorInstances( } } +/** + * ajv reports `const` as its own keyword, but a `const` is just an `enum` with a + * single allowed value. Rewriting it as one lets the existing `enum` handling + * apply: redundant branches of a `oneOf` get filtered out, sibling branches + * merge into a single error, and the reader gets a "Did you mean" suggestion. + */ +const constToEnum = (error: AjvError): AjvError => + error.keyword === 'const' + ? ({ + ...error, + keyword: 'enum', + params: { allowedValues: [(error.params as ConstParams).allowedValue] }, + } as AjvError) + : error; + export default ( ajvErrors: AjvError[] | null | undefined, options: ErrorContext ): BaseValidationError[] => { - const tree = makeTree(ajvErrors || []); + const tree = makeTree((ajvErrors || []).map(constToEnum)); filterRedundantErrors(tree); return createErrorInstances(tree, options); }; diff --git a/src/validation-errors/const.ts b/src/validation-errors/const.ts deleted file mode 100644 index 2b9b56d9..00000000 --- a/src/validation-errors/const.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { styleText } from 'node:util'; -import type { IOutputError } from '../index'; -import type { ConstParams } from '../types'; -import BaseValidationError from './base'; - -// The allowed value is printed as JSON so a string is quoted and tells the reader -// that the schema expects exactly that text. -const toText = (value: unknown): string => JSON.stringify(value) ?? String(value); - -export default class ConstValidationError extends BaseValidationError { - print(): string[] { - const { - message, - params: { allowedValue }, - } = this.options; - - const output = [ - styleText('red', styleText('bold', 'CONST') + ' ' + message), - styleText('red', `(${toText(allowedValue)})\n`), - ]; - - return output.concat( - this.getCodeFrame( - '👈🏽 Should be equal to ' + styleText('magentaBright', toText(allowedValue)) - ) - ); - } - - getError(): IOutputError { - const { - message, - params: { allowedValue }, - } = this.options; - - return { - ...this.getLocation(), - error: this.withDecoratedPath(`${message}: ${toText(allowedValue)}`), - path: this.instancePath, - }; - } -} diff --git a/src/validation-errors/index.ts b/src/validation-errors/index.ts index 4ba3cb8f..39ff94b8 100644 --- a/src/validation-errors/index.ts +++ b/src/validation-errors/index.ts @@ -1,5 +1,4 @@ export { default as RequiredValidationError } from './required'; export { default as AdditionalPropValidationError } from './additional-prop'; export { default as EnumValidationError } from './enum'; -export { default as ConstValidationError } from './const'; export { default as DefaultValidationError } from './default';