Skip to content

feat: include the expected value in const errors - #326

Open
Dhairya-007 wants to merge 5 commits into
atlassian:mainfrom
Dhairya-007:feat/const-expected-value
Open

Dhairya-007 wants to merge 5 commits into
atlassian:mainfrom
Dhairya-007:feat/const-expected-value

Conversation

@Dhairya-007

Copy link
Copy Markdown

Closes #162.

The issue asked whether the expected constant could be included in the message. ajv already hands it to us in params.allowedValue; it was just being dropped, because const had no handler and fell through to DefaultValidationError.

Before / after

Using the exact schema from the issue:

- /node: const must be equal to constant
+ /node must be equal to constant: ">=18.12.0"

CLI format:

CONST must be equal to constant
(">=18.12.0")

  1 | {
  2 |   "engines": {
> 3 |     "node": ">=18.12.1"
    |             ^^^^^^^^^^^ 👈🏽  Should be equal to ">=18.12.0"
  4 |   }
  5 | }

Approach

A ConstValidationError class, modelled on EnumValidationError — which already appends its allowed values in the same shape — rather than special-casing const inside the default handler. So: new ConstParams type, the class, the barrel export, and one case 'const': in createErrorInstances.

The expected value is printed with JSON.stringify, not String. For a single value that's worth it: it keeps ">=18.12.0" distinguishable from a bare token, and null, false and 8080 print unambiguously. EnumValidationError uses String because it joins a list, where quoting every item would be noisy — happy to match it instead if you'd rather have them identical.

Tests

Three integration tests in src/__tests__/index.ts (real ajv, then toEqual on the js output — matching the newer tests in that file rather than adding fixtures and snapshots):

  • the issue's case, with the expected value in the message
  • non-string constants: 8080, null, false
  • the cli output contains the value, both in the header and the codeframe pointer

With the case 'const': routing reverted, all three fail with the old "/node: const must be equal to constant", so they're pinning the actual behaviour.

No existing test or snapshot covered const, so nothing else moved.

Checks

vp test 84/84, format:check clean, lint (vp lint + publint) clean.

Note: run on Node 22. On Node 20 the build step (and so lint) fails with Promise.withResolvers is not a function inside vite-plus, which also happens on a clean main — unrelated to this PR.

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.
Covers the js and cli formats, and non-string constants.
@changeset-bot

changeset-bot Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 177154d

The changes in this PR will be included in the next version bump.

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@atlassian-cla-bot

atlassian-cla-bot Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

Hooray! All contributors have signed the CLA.

@codecov-commenter

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.41%. Comparing base (eaa5a3c) to head (1472525).
⚠️ Report is 51 commits behind head on main.
❗ Your organization needs to install the Codecov GitHub app to enable full functionality.

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #326      +/-   ##
==========================================
+ Coverage   90.78%   98.41%   +7.63%     
==========================================
  Files          12       15       +3     
  Lines         217      316      +99     
  Branches       69       81      +12     
==========================================
+ Hits          197      311     +114     
+ Misses         19        5      -14     
+ Partials        1        0       -1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@torifat torifat left a comment

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.

Thanks for this. The oneOf case in helpers.ts is the main one, the rest is small.

Comment thread src/helpers.ts Outdated
return ret.concat(
new RequiredValidationError(error as AjvError<RequiredParams>, options)
);
case 'const':

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.

const skips the enum handling in filterRedundantErrors and the merge above, which breaks oneOf unions. With

oneOf: [
  { properties: { kind: { const: 'a' }, x: { type: 'number' } } },
  { properties: { kind: { const: 'b' }, y: { type: 'string' } } },
]

and { kind: 'a', x: 'nope' }, we now print /kind must be equal to constant: "b". That tells the user to change the one value that's right. enum: ['b'] there gets filtered out. Two anyOf branches with const: 'a' and const: 'b' also give two errors on /kind instead of one.

Could const be handled as a one-value enum, allowedValues: [params.allowedValue]? That fixes both and adds the "Did you mean" suggestion.

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.

Done , const is now rewritten to { keyword: 'enum', params: { allowedValues: [allowedValue] } } before the tree is built, so it goes through the enum path. Your example now gives /x: type must be number plus the oneOf error, with no /kind error, and the two anyOf branches collapse into one.

Comment thread src/validation-errors/const.ts Outdated

// 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);

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.

JSON.stringify throws on a circular object or a BigInt, so betterAjvErrors would now throw where it used to return errors. It also prints NaN and Infinity as null. A try/catch falling back to String(value) would cover the throw.

Also, enum's toText uses String, so enum: ['a'] prints a and const: 'a' prints "a". I'd keep them the same.

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.

Resolved by removing the file. The shared enum rendering uses String(), so circular objects, BigInt, NaN and Infinity no longer throw, and const matches enum by construction rather than by agreement.

Comment thread src/validation-errors/const.ts Outdated

const output = [
styleText('red', styleText('bold', 'CONST') + ' ' + message),
styleText('red', `(${toText(allowedValue)})\n`),

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.

A large object constant prints in full here and again in the pointer line. Maybe truncate it past some length?

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.

Nothing is truncated, but nothing long is printed either: String() gives [object Object] for an object and 1,2,3 for an array. The length problem is gone, at the cost of an object constant no longer showing its contents. That matches enum: [someObject] today. If you'd rather keep the detail, I'll add a shared toText on BaseValidationError that stringifies with a try/catch and truncates, used by both.

Comment thread .changeset/const-expected-value.md Outdated
"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.

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.

The cli header doesn't change, it's still CONST must be equal to constant. The value goes on the next line and in the pointer.

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.

Reworded to describe the enum rewrite and drop the header claim.

Comment thread src/__tests__/index.ts
]);
});

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.

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.
@Dhairya-007

Copy link
Copy Markdown
Author

Thanks, the oneOf point was right and the one-value-enum suggestion turned out to fix all three code comments at once. const is now rewritten to { keyword: 'enum', params: { allowedValues: [allowedValue] } } before the tree is built, so it goes through the existing enum path rather than around it. ConstValidationError and its toText are gone.

Your example now gives:

/x: type must be number
oneOf must match exactly one schema in oneOf

/kind must be equal to constant: "b" is no longer reported, and the two anyOf branches collapse to a single error. Since the enum path is shared, the did-you-mean suggestion comes along too, and the circular/BigInt/NaN problems disappear with JSON.stringify — enum's String-based rendering handles them. There is a test asserting const and enum produce the same paths and counts for both the oneOf and anyOf shapes, plus the circular constant you asked for.

Two things worth your call:

  • Sibling consts are now filtered like sibling enums. { port: { const: 8080 }, flag: { const: false } } failing on both used to report two errors and now reports one, because filterRedundantErrors drops enum nodes whose siblings also have errors. It matches enum exactly, but it is a visible change from main. Happy to special-case it if you would rather keep both.
  • const: null renders as an empty value, the same way enum: [null] does today (asserted in the existing allowed values: , 5 test). I left that alone rather than widen this PR, but it looks like a real bug in EnumValidationError.getError(), which uses a raw join() while print() works around it. Glad to fix it separately.

The changeset is reworded — you were right that the cli header never changed.

@Dhairya-007

Copy link
Copy Markdown
Author

One more on your truncation point, which I glossed over above. Nothing is truncated, but nothing long is printed either: the shared enum rendering uses String(), so an object constant comes out as [object Object] and an array as 1,2,3. So the length problem is gone, at the cost of an object constant no longer showing its contents, where the previous JSON.stringify showed them in full.

That is exactly what enum: [someObject] already does today, so it is consistent, but it is a real loss of detail against the version you reviewed. If you would rather keep the detail, the natural place is a shared toText on BaseValidationError that JSON-stringifies with a try/catch and truncates past some length, used by both enum and const. Say the word and I will do that here, otherwise I will leave the behaviour matching enum.

@Dhairya-007
Dhairya-007 requested a review from torifat November 7, 2026 15:31
@Dhairya-007

Copy link
Copy Markdown
Author

Hii @torifat , good for re-review now

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Show actual incorrect constant rather than "const must be equal to constant"

3 participants