perf: skip the full scan in min*/max* string-metric actions when String#length already settles the verdict
This issue was created based on a draft provided by the agent, due to my limited English fluency and the advanced nature of the topic. However, I can explain what I want to achieve, so please feel free to ask if you have any questions. Thank you for your time!
Summary
The minCodePoints / maxCodePoints / minGraphemes / maxGraphemes / minBytes / maxBytes / maxWords actions always compute the exact metric over the whole input, even when input.length alone is enough to prove the input passes. Because each metric is bounded by the UTF-16 code unit length, a single O(1) comparison can settle most real-world inputs without touching the string at all.
tl;dr: Since String.length is always equal to or greater than the character count (accounting for code points and surrogate pairs), checking it alone is sometimes sufficient.
Current behavior
Every one of these actions unconditionally calls its counting helper:
// src/actions/maxCodePoints/maxCodePoints.ts
'~run'(dataset, config) {
if (dataset.typed) {
const count = _getCodePointCount(dataset.value); // always scans the entire string
if (count > this.requirement) _addIssue(this, 'code points', dataset, config, { received: `${count}` });
}
return dataset;
}_getCodePointCount walks the whole string, _getByteCount allocates a Uint8Array via TextEncoder#encode, and _getGraphemeCount / _getWordCount iterate an Intl.Segmenter. The last one is much more expensive than the comparison it feeds.
The most striking case is minCodePoints(1), which is just a non-empty check but scans the entire string to compute a number that is then compared against 1.
Proposal
For every metric M there is a bound relating it to L = input.length. When the bound proves the input passes, the action can return immediately. And since no issue is raised, the exact count is never needed for the received field.
| Action | Bound | Skip the count when |
|---|---|---|
maxCodePoints(n) |
C ≤ L |
L <= n |
minCodePoints(n) |
C ≥ ⌈L/2⌉ |
(L + 1) >> 1 >= n |
maxGraphemes(n) |
G ≤ L |
L <= n |
maxWords(n) |
W ≤ L |
L <= n |
maxBytes(n) |
B ≤ 3L |
L * 3 <= n |
minBytes(n) |
B ≥ L |
L >= n |
notCodePoints(n) |
both above | bounds exclude n |
The min counterparts for graphemes and words have no useful lower bound (one grapheme cluster can span arbitrarily many code units), so they are out of scope apart from the degenerate minGraphemes(1).
The change is local to each ~run:
'~run'(dataset, config) {
if (dataset.typed) {
// codePointCount <= length, so length <= requirement already settles it
if (dataset.value.length > this.requirement) {
const count = _getCodePointCount(dataset.value);
if (count > this.requirement) _addIssue(this, 'code points', dataset, config, { received: `${count}` });
}
}
return dataset;
}Benchmarks
This issue was made by agent and reviewed by me. My agent seems made a prototype actions with the identical ~run shape, measured against the shipped ones through the same condition.
Observed in Node v26.4.0, valibot 1.5.0, Linux (WSL2); 20k–200k iterations per case after warm-up.
| Case | Current | Proposed |
|---|---|---|
minCodePoints(1) on 1500 ASCII chars |
3405 ns | 39 ns |
maxCodePoints(3000) on 140 ASCII chars |
343 ns | 22 ns |
maxCodePoints(3000) on 1200 mixed CJK/emoji code points |
4267 ns | 33 ns |
maxGraphemes(3000) on 140 ASCII chars |
15236 ns | 35 ns |
maxBytes(8192) on 140 ASCII chars |
598 ns | 24 ns |
maxCodePoints(100) on 1200 code points (fails) |
4322 ns | 4412 ns |
The failing case is the one that cannot benefit, because the exact count is still needed for expected m, but received n message. However, if there is no need to retrieve the number of code points received, I believe this could also be a candidate for early termination (capped mode).
Questions
- Should the counting helpers also gain a capped mode (stop at
requirement + 1) for the inputs the bounds do not settle? It would speed up the failure path, butreceivedcould then only be reported as>n, which changes the message. - Would you prefer the bound checks inlined per action, or a shared helper such as
_settledByLength(length, requirement, kind)? Inlining keeps the tree-shaken bundle smaller; a helper keeps the bound table in one place.
Source: open-circle/valibot