Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/const-expected-value.md
Original file line number Diff line number Diff line change
@@ -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` 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.
118 changes: 118 additions & 0 deletions src/__tests__/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,124 @@ describe('Main', () => {
]);
});

describe('const', () => {

@torifat torifat Nov 5, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add a oneOf case like the one in helpers.ts, and a circular constant once toText handles it?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added: a oneOf case asserting no /kind error, the circular constant, and a parity test that const and enum give the same paths and counts for both the oneOf and anyOf shapes.

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',
suggestion: 'Did you mean >=18.12.0?',
},
]);
});

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',
oneOf: [
{ properties: { kind: { const: 'a' }, x: { type: 'number' } } },
{ properties: { kind: { const: 'b' }, y: { type: 'string' } } },
],
};
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<string, unknown> = { 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', () => {
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('must be equal to constant');
expect(output).toContain('(>=18.12.0)');
expect(output).toContain('Did you mean >=18.12.0 here?');
});
});

describe('property names with characters outside word characters and -', () => {
const getErrors = (schema: SchemaObject, data: unknown) => {
const validate = new Ajv({ allErrors: true }).compile(schema);
Expand Down
19 changes: 18 additions & 1 deletion src/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type {
AdditionalPropertiesParams,
AjvError,
ConstParams,
EnumParams,
ErrorContext,
ErrorNode,
RequiredParams,
Expand Down Expand Up @@ -187,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<EnumParams>)
: 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);
};
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export interface AjvError<Params = Record<string, unknown>> {
}

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.
Expand Down