RegExp.test() with /g Flag: The Stateful Matcher That Skips Every Other String2026-06-02
This function categorizes strings into those containing a digit and those without. It uses a module-level regex (because allocating one per call would be wasteful) and walks the input once.
const HAS_DIGIT = /\d/g;
function categorize(strings) {
const withDigits = [];
const without = [];
for (const s of strings) {
if (HAS_DIGIT.test(s)) {
withDigits.push(s);
} else {
without.push(s);
}
}
return { withDigits, without };
}
const { withDigits, without } = categorize(
["abc1", "def2", "ghi3", "jkl4", "mno"]
);
console.log(withDigits); // expected: ["abc1","def2","ghi3","jkl4"]
console.log(without); // expected: ["mno"]
Unit tests with a single string pass. Tests with a freshly constructed regex inside the loop pass. But in production, roughly half the digit-bearing strings get misfiled into without. Worse, the exact set that goes missing depends on the order of the input.
The /g flag turns HAS_DIGIT into a stateful object. Each successful test() advances the regex's internal lastIndex property to the character after the match. The next call starts scanning from lastIndex — even if you pass a completely different string.
Walk through the input:
"abc1": lastIndex=0, finds "1" at index 3 → true, lastIndex=4."def2": lastIndex=4, but the string is only 4 chars long → false, lastIndex resets to 0."ghi3": lastIndex=0 → true, lastIndex=4."jkl4": lastIndex=4 → false, reset."mno": no match → false.The function reports withDigits = ["abc1","ghi3"] and without = ["def2","jkl4","mno"]. The same regex object is being asked "where's the next match in this conceptually-continuing stream?" — except the stream changes underneath it. JavaScript shares this footgun with the global RegExp.exec() and with regexes carrying the /y (sticky) flag, but /g is by far the most common source.
The /g flag looks innocuous on test() — there's no "global" semantics in a boolean answer. Many developers add it reflexively, or copy it from a regex that was previously used with String.replace. Linters typically don't flag it. And the bug only manifests when the regex is reused — single-call usage and freshly-created regexes both work fine, which is exactly what tests tend to exercise.
Drop the /g flag. For a yes/no membership question, you don't need it:
const HAS_DIGIT = /\d/; // no /g
function categorize(strings) {
const withDigits = [];
const without = [];
for (const s of strings) {
(HAS_DIGIT.test(s) ? withDigits : without).push(s);
}
return { withDigits, without };
}
If you genuinely need /g (e.g., you're iterating matches with exec), either reset HAS_DIGIT.lastIndex = 0 before each new string, or use String.prototype.matchAll, which returns a fresh iterator and doesn't mutate the regex.
/g (or /y) is a stateful object — reusing it across test() or exec() calls makes results depend on call history, so omit the flag when you only need a boolean.
