#1650·valibot

perf: skip the full scan in min*/max* string-metric actions when String#length already settles the verdict

Author: kakkokari-gtyihCreated Sep 18, 2026Updated Sep 18, 2026

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:

javascript
// 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:

javascript
'~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

  1. 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, but received could then only be reported as >n, which changes the message.
  2. 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.