JAVASCRIPT / REGULAR EXPRESSIONS
Flags for global, case-insensitive, and sticky search
Use the g, i, and y flags deliberately and manage the lastIndex cursor that makes global and sticky regexes stateful between calls.
What you will learn
- Read and set lastIndex to control where a g or y regex starts its next attempt
- Reset lastIndex before reusing a /g/ regex with test() on a different string
- Choose y over g when the next match must be adjacent, as in a tokenizer
- Remember matchAll and replaceAll throw TypeError on a regex without g
Understanding Flags for global, case-insensitive, and sticky search
A flag is a modifier written after the closing slash of a literal, or as the second argument to new RegExp(source, flags). It does not change what the pattern describes, only how the engine applies it: i canonicalizes the pattern character and the input character before comparing them, while g and y decide where an attempt is allowed to begin. That distinction is why /cat/i still matches one thing, whereas /cat/g matches the same thing over and over.
The g and y flags turn the regex object into a cursor with memory. Every RegExp has a writable lastIndex property, and when either flag is set, exec and test start at lastIndex, move it to the position just after a successful match, and reset it to 0 when the attempt fails. Two consequences follow: a global regex held in a variable carries state across calls, so it is not a pure predicate, and a pattern that can match the empty string never advances lastIndex, which turns while (re.exec(s)) into an infinite loop.
Sticky matching uses that same cursor but forbids the forward scan: the match must succeed at exactly lastIndex or it fails. Think of y as a ^ you can move, which is precisely the contract a tokenizer needs, since every character has to be accounted for, while g is a search that happily steps over anything it does not understand. Methods that only inspect the global flag, such as match, matchAll, and replaceAll, use it as a switch between first-match and all-matches semantics, which is why the last two reject a non-global regex outright.
const text = "cat Cat cAt";
// i only changes how characters are compared
console.log(/cat/.test("CAT") + " " + /cat/i.test("CAT"));
// g: exec scans forward from lastIndex and leaves it after the match
const g = /cat/gi;
let m;
while ((m = g.exec(text)) !== null) {
console.log("g found " + m[0] + " at " + m.index + ", lastIndex=" + g.lastIndex);
}
console.log("after the failing call lastIndex=" + g.lastIndex);
// y: the match must start exactly at lastIndex, scanning is not allowed
const y = /cat/iy;
console.log("y " + y.test(text) + ", lastIndex=" + y.lastIndex);
console.log("y " + y.test(text) + ", lastIndex=" + y.lastIndex);g and y do not just repeat a match, they make the regex object stateful through lastIndex, and y additionally forbids scanning forward from it.
Worked examples
A shared global regex breaks test()
Reusing one /g/ regex across several strings makes test() alternate between true and false.
const re = /\d+/g;
for (const s of ["a1", "b2", "c3"]) {
console.log(s + " -> " + re.test(s) + " (lastIndex=" + re.lastIndex + ")");
}
re.lastIndex = 0;
console.log("reset then b2 -> " + re.test("b2"));Example explained
Line 1const re = /\d+/g creates one object whose lastIndex survives every call to test().
Line 2After matching "1" in "a1", lastIndex is 2, so the attempt on "b2" starts at the end of that string and finds nothing.
Line 3That failure resets lastIndex to 0, which is why "c3" succeeds again and the results alternate.
Line 4Assigning re.lastIndex = 0 (or dropping g for a plain yes/no check) makes each call independent.
Sticky matching for a tokenizer
y refuses to skip characters, so unexpected input fails at a known offset instead of being ignored.
const token = /\s*(\d+|[+*])\s*/y;
const src = "12 + 3 ? 4";
let pos = 0;
while (pos < src.length) {
token.lastIndex = pos;
const t = token.exec(src);
if (t === null) break;
console.log("token " + t[1]);
pos = token.lastIndex;
}
console.log(pos < src.length ? "stopped at index " + pos : "consumed everything");
console.log("with g: " + src.match(/\d+|[+*]/g).join(" "));Example explained
Line 1token.lastIndex = pos anchors each attempt where the previous token ended, so no character can be passed over.
Line 2At index 7 the '?' matches neither branch, exec returns null, and the loop can report the exact offset of the bad input.
Line 3Keeping pos in a separate variable is necessary because the failed sticky match already reset token.lastIndex to 0.
Line 4The same pattern with g jumps across the '?' and reports 4, so the invalid input looks valid.
Flags that methods inspect themselves
replace obeys g, flags reports them in canonical order, and replaceAll demands g.
const s = "Red red RED";
console.log(s.replace(/red/i, "blue"));
console.log(s.replace(/red/gi, "blue"));
const re = new RegExp("red", "ig");
console.log(re.flags + " " + re.global + " " + re.ignoreCase + " " + re.sticky);
try {
s.replaceAll(/red/i, "blue");
} catch (e) {
console.log(e.name + " from replaceAll without g");
}Example explained
Line 1Without g, replace() stops after one substitution; i only decides that "Red" counts as a match at all.
Line 2The flags getter rebuilds the string in a fixed order, so "ig" comes back as "gi", and global/ignoreCase/sticky are read-only booleans.
Line 3replaceAll checks the g flag itself and throws instead of quietly replacing once; matchAll rejects a non-global regex the same way.
Important notes
i applies simple case folding, not locale rules, so /straße/i.test("STRASSE") is false and Turkish dotted and dotless i do not match each other.
If both g and y are set, the sticky restriction still applies to exec and test, while methods that branch on the global flag keep their all-matches behaviour.
Common mistakes
Storing const re = /\d+/g in module scope and calling re.test(value) for many values: the carried-over lastIndex makes roughly every other check fail, and the bug disappears whenever you test a single input.
Writing while (re.exec(s)) with a regex that has no g or y flag: lastIndex is never consulted or advanced, exec keeps returning the same first match, and the loop never ends.
Assuming str.replace(/x/i, ...) replaces every occurrence: it changes only the first one, and switching to str.replaceAll(/x/i, ...) to fix it throws a TypeError because the regex is not global.
Try it yourself
Change, predict, then run
In a browser console, tokenize "aa bb 3cc" with const re = /[a-z]+|\s+/y, logging each match and re.lastIndex until exec returns null, and print the offset where it stopped. Then change the flag to g and confirm the digit is silently skipped instead of reported.
Open the JavaScript workspaceCheck your understanding
A module defines const re = /^\d{3}$/g and isCode(s) { return re.test(s); }. Calling isCode("123") twice returns true, then false. Why?
- test() caches its result per input string and returns the inverse on a repeated call.
- The g flag lets ^ match only the first time a given string is tested.
- The first call leaves re.lastIndex at 3, so the second attempt starts at index 3 where ^ cannot match.
- Regex literals are re-created on every call, so shared state cannot be the cause.
Show answer
With g set, test() starts at re.lastIndex and, after consuming all three digits, leaves it at 3; the next call therefore begins at index 3, where ^ fails without the m flag, and that failure resets lastIndex to 0 so the results alternate. The option about ^ is tempting but wrong: anchors have no per-string memory and are evaluated fresh on each attempt, the state lives entirely in lastIndex. Removing g, or setting re.lastIndex = 0 first, fixes it.