Skip to content
Merged

Dev #53

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
15 changes: 14 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
## Changelog

### [v7.0.0](https://github.com/panates/valgen/compare/v6.2.2...v7.0.0) -
### [v7.0.1](https://github.com/panates/valgen/compare/v7.0.0...v7.0.1) -

#### 🪲 Fixes

- fix: oneOf was crushing the real error when every candidate failed @Eray Hanoğlu
- fix: oneOf reports the actual candidate error directly, not a generic one @Eray Hanoğlu

#### 📖 Documentation Changes

- docs: track a baseline commit for the API reference, document the practice @Eray Hanoğlu
- docs: bump API docs baseline to the oneOf fix commit @Eray Hanoğlu
- docs: bump API docs baseline to the oneOf direct-error-message fix @Eray Hanoğlu

## [v7.0.0](https://github.com/panates/valgen/compare/v6.2.2...v7.0.0) - 8 September 2026

#### 🚀 New Features

Expand Down
16 changes: 16 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,19 @@ Rules:
- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).

## API docs baseline (docs/api.md, docs/api/*.md)

`docs/api.md` starts with an HTML comment block (`docs-baseline`) recording the git commit,
package version, and date the API docs were last verified against source - see that block for
the exact format and the `git diff <commit>..HEAD -- src/` command it documents.

Rules:
- Whenever you write or update these API docs, record (or update) that baseline block with the
commit you verified against - so a later session can diff from a known point instead of
re-reading everything from scratch.
- Before trusting/updating the docs, diff `src/` (and `test/**/*.spec.ts` for examples) between
the recorded commit and `HEAD` to see what actually changed, then update only the affected
doc section(s) - don't regenerate everything unless the diff is broad enough to warrant it.
- After updating, bump `git-commit`/`package-version`/`date` in the baseline block to the new
`HEAD` (only once the docs are verified accurate as of that commit).
15 changes: 15 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
# API Reference

<!--
docs-baseline:
Everything under docs/api.md and docs/api/*.md was written and verified (every
signature/option/example checked against source and test/**/*.spec.ts, or run
directly against the library) as of the commit and package version below.

git-commit: f6c54906b3fc94b37401ac7b94788ff58ccea51b
package-version: 7.0.0
date: 2026-09-10

To refresh after later changes: `git diff f6c54906b3fc94b37401ac7b94788ff58ccea51b..HEAD -- src/`
(or a narrower path for one category) to see what moved since this baseline, update the
affected doc section(s), then bump git-commit/package-version/date above to the new HEAD.
-->

valgen validators all share the same small set of conventions. This page covers those shared
conventions once; the per-category pages document every individual validator function.

Expand Down
6 changes: 3 additions & 3 deletions docs/api/utility-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ oneOf(rules: (Validator | [Validator, Record<string, Validator>])[], options?: o
- For a plain entry, `oneOf` calls it directly; if it throws or fails, it moves on to the next entry (short-circuits on the **first success**, not the first failure).
- For a `[validator, discriminator]` tuple, `input` must be an object. `oneOf` first runs each rule in `discriminator` against the matching property of `input` (e.g. `discriminator.kind(input.kind)`); only if **every** discriminator key passes does it go on to run the tuple's main `validator` against the whole `input`. If any discriminator key fails (or `input` isn't an object), that entry is skipped entirely — the main validator never runs — and `oneOf` moves to the next candidate. This lets you dispatch between differently-shaped objects using a cheap "tag" check (e.g. a `kind` field) instead of trying and catching a full shape validation for each candidate.
- An unexpected exception thrown by a discriminator or a rule (as opposed to a normal validation failure) is caught and treated the same as a failure — `oneOf` just moves on to the next candidate.
- If no entry passes, it fails with `Value didn't match one of required rules`.
- If no entry passes, `oneOf` reports the *last* candidate's own failure message directly (a normal validation message, or an unexpected exception's message) rather than a generic one - since it can only report a single message anyway, the actual reason is more useful than a vague "didn't match". The generic `Value didn't match one of required rules` message is only used as a fallback when nothing was actually tried (e.g. an empty `rules` array).

**Example**
```ts
Expand All @@ -191,7 +191,7 @@ import { isNull, isNumber, isObject, isString, vg } from 'valgen';
const simple = vg.oneOf([isNull, isNumber]);
simple(6); // => 6
simple(null); // => null
simple('x'); // throws: "Value didn't match one of required rules"
simple('x'); // throws: "Value must be a number" (the last candidate's own error, reported directly)

// discriminated form: pick the object shape based on `kind`
const pet = vg.oneOf([
Expand All @@ -204,7 +204,7 @@ pet({ kind: 'cat', name: 'Molly' }); // => { kind: 'cat', name: 'Molly' }
pet({ kind: 'dog', name: 'Daisy' }); // => { kind: 'dog', name: 'Daisy' }
pet('Daisy'); // => 'Daisy'
pet(5); // => 5
pet({ kind: 'bird', name: 'Bluey' }); // throws: "Value didn't match one of required rules"
pet({ kind: 'bird', name: 'Bluey' }); // throws: "Value must be equal to \"5\"" (the last candidate tried)
```

## optional
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "valgen",
"description": "Fast runtime type validator, converter and io (encoding/decoding) library",
"version": "7.0.0",
"version": "7.0.1",
"author": "Panates",
"license": "MIT",
"private": true,
Expand Down
40 changes: 34 additions & 6 deletions src/rules/utility-rules/one-of.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,21 @@ export function oneOf(
let discriminator: DiscriminatorRecord | undefined;
let v: any;
let passed = false;
// Mock fail method to prevent errors
context.fail = () => (passed = false);
// Every candidate failing is normal control flow here (that's how
// "try the next one" works), so a candidate's own context.fail must
// not throw or accumulate into the real error list. But swallowing it
// completely would hide the *reason* every candidate failed - including
// a genuine bug in a candidate rule, which would otherwise look
// identical to "the input just didn't match". So the mock still
// records the last failure's message, and it's reported directly as
// the final error (oneOf can only report one message anyway) instead
// of being discarded in favor of a generic one.
let lastFailMessage: string | undefined;
context.fail = (_rule: Validator, message: string | Error) => {
passed = false;
lastFailMessage =
message instanceof Error ? message.message : String(message);
};
for (i = 0; i < l; i++) {
passed = true;
if (Array.isArray(rules[i])) {
Expand All @@ -51,22 +64,37 @@ export function oneOf(
if (!passed) break;
}
if (!passed) continue;
} catch {
} catch (e: any) {
// A discriminator/rule that throws directly (bypassing
// context.fail entirely, e.g. a plain function rather than one
// built with validator()) must still count as "this candidate
// failed" - otherwise `passed` is left at its top-of-loop `true`
// and, if this is the last candidate, oneOf would silently
// return an unvalidated value instead of failing.
passed = false;
lastFailMessage =
e?.message != null ? String(e.message) : String(e);
continue;
}
} else c = rules[i] as Validator;
if (passed)
try {
v = c(input, undefined, context);
if (passed) break;
} catch {
//
} catch (e: any) {
passed = false;
lastFailMessage =
e?.message != null ? String(e.message) : String(e);
}
}
// Restore fail method
delete (context as any).fail;
if (passed) return v;
context.fail(_this, `Value didn't match one of required rules`, input);
context.fail(
_this,
lastFailMessage || `Value didn't match one of required rules`,
input,
);
},
options,
);
Expand Down
29 changes: 20 additions & 9 deletions test/utility-rules/one-of.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,8 @@ describe('oneOf', () => {
const c = vg.oneOf([isNull, isNumber]);
expect(c(6)).toStrictEqual(6);
expect(c(null)).toStrictEqual(null);
expect(() => c('x' as any)).toThrow(
"Value didn't match one of required rules",
);
// Reports the last candidate's own error directly, not a generic message.
expect(() => c('x' as any)).toThrow('Value must be a number');
});

it('should return one of valid object using discriminator', () => {
Expand Down Expand Up @@ -39,13 +38,13 @@ describe('oneOf', () => {
expect(c('Daisy')).toStrictEqual('Daisy');
expect(c(5)).toStrictEqual(5);
expect(c(null)).toStrictEqual(null);
expect(() => c(1)).toThrow("Value didn't match one of required rules");
expect(() => c(1)).toThrow('Value must be equal to "5"');
expect(() =>
c({
kind: 'bird',
name: 'Bluey',
}),
).toThrow("Value didn't match one of required rules");
).toThrow('Value must be equal to "5"');
});

it('should move on to the next rule if a discriminator throws unexpectedly', () => {
Expand All @@ -54,9 +53,8 @@ describe('oneOf', () => {
}) as any;
const c = vg.oneOf([[isObject, { kind: throwing }], isNumber]);
expect(c(5)).toStrictEqual(5);
expect(() => c({ kind: 'x' } as any)).toThrow(
"Value didn't match one of required rules",
);
// isNumber (the next candidate) is what ultimately fails and reports.
expect(() => c({ kind: 'x' } as any)).toThrow('Value must be a number');
});

it('should move on to the next rule if a rule throws unexpectedly', () => {
Expand All @@ -65,7 +63,20 @@ describe('oneOf', () => {
}) as any;
const c = vg.oneOf([throwing, isNumber]);
expect(c(5)).toStrictEqual(5);
expect(() => c('x' as any)).toThrow(
expect(() => c('x' as any)).toThrow('Value must be a number');
});

it('should report the actual candidate error directly instead of a generic message', () => {
const buggy = (() => {
throw new TypeError('unexpected bug');
}) as any;
const c = vg.oneOf([buggy]);
expect(() => c('anything')).toThrow('unexpected bug');
});

it('should still fall back to a generic message when there is nothing to report', () => {
const c = vg.oneOf([]);
expect(() => c('anything' as any)).toThrow(
"Value didn't match one of required rules",
);
});
Expand Down