GAP·MAP

Regular expressions

[ junior depth ]

Character classes usually select one character

Square brackets define a character class. In the patterns used below, the class matches one character from its set, even when several characters appear between the brackets. The newer v flag also permits class members that match finite-length strings.

/[abc]/.test("b");       // true
/[a-z]/.test("m");      // true
/[^0-9]/.test("7");     // false
/[^0-9]/.test("x");     // true

^ complements a class only when it appears at the start of the class. [a^b] includes a literal ^. The common escapes \d, \w, and \s mean digit, word character, and whitespace. Their uppercase forms are the complements. In JavaScript, \d is equivalent to [0-9], and \w is based on ASCII letters, digits, and underscore, with a limited addition from Unicode case folding when both u and i are active.

Wrong "[cat|dog] matches either the word cat or the word dog."

Right It matches one character from the set. Use a group with alternatives for whole words.

/^(?:cat|dog)$/.test("dog"); // true
/^[cat|dog]$/.test("dog");   // false

Capturing and non-capturing groups

Parentheses group a subpattern so alternation and quantifiers apply to the whole unit. A plain group (pattern) also captures its matched substring. Element zero of an exec() result is the entire match, followed by numbered captures in the order of their opening parentheses.

const match = /(cat|dog)-([0-9]+)/.exec("dog-42");

match[0]; // "dog-42"
match[1]; // "dog"
match[2]; // "42"

Use (?:pattern) when parentheses are needed only for grouping. It does not add a capture to the result. This also prevents later edits from shifting numbered captures.

const match = /(?:cat|dog)-([0-9]+)/.exec("dog-42");

match[0]; // "dog-42"
match[1]; // "42"

Quantifiers are greedy by default

?, *, +, and {min,max} repeat the preceding atom. An atom can be a literal, a class, or a group. Greedy quantifiers first try as many repetitions as possible, then give some back if the rest of the pattern cannot match. Add ? to make a quantifier lazy. A lazy quantifier starts with as few repetitions as possible and expands when the rest of the pattern requires it.

"<b>one</b><i>two</i>".match(/<.*>/)[0];  // "<b>one</b><i>two</i>"
"<b>one</b><i>two</i>".match(/<.*?>/)[0]; // "<b>"

Lazy does not mean faster, and greedy does not mean the engine refuses to backtrack. Both describe which repetition count is tried first.

Six commonly used flags

Flags change how the pattern is interpreted or how it searches:

  • g requests global search. exec() and test() use and update lastIndex when it is present.
  • i enables case-insensitive matching.
  • m lets ^ and $ also match line boundaries.
  • s lets . match line terminators.
  • u enables Unicode-aware parsing and treats surrogate pairs as whole code points during matching.
  • y requires a match to start exactly at lastIndex.

JavaScript also has d, which adds capture indices to match results, and v, which extends Unicode-aware mode with set operations and finite-length strings in character classes. The u and v flags cannot be used together.

m does not make dot cross a newline. That behavior belongs to s.

/^two$/m.test("one\ntwo"); // true
/one.two/s.test("one\ntwo"); // true
[ middle depth ]

Lookarounds assert without consuming text

A lookahead checks the text to the right of the current position. A lookbehind checks the text to the left. Positive forms require a match; negative forms require the pattern not to match. The assertion does not consume the text it inspects.

"USD 25 EUR 30".match(/[0-9]+(?= EUR)/)[0];  // "25"
"USD 25 EUR 30".match(/(?<=USD )[0-9]+/)[0]; // "25"

/foo(?!bar)/.test("foobaz");  // true
/(?<!USD )[0-9]+/.test("EUR 30"); // true

The text checked by (?=...) and (?<=...) stays outside the full match. Capturing groups inside an assertion can still produce captures, but that combination has subtler backtracking rules and requires explicit tests.

Wrong "Lookbehind consumes the prefix and removes it from later matching."

Right A lookbehind verifies the prefix while leaving the current position unchanged.

Backreferences require the same text

A backreference matches the substring previously captured by a group. \1 refers to the first capturing group, while \k<name> refers to a named group. It does not rerun the group's pattern against new text.

const repeatedWord = /\b(?<word>[A-Za-z]+)\s+\k<word>\b/i;

repeatedWord.test("Go go");   // true
repeatedWord.test("go gone"); // false

Named captures reduce refactoring hazards because inserting another capture does not change the reference. Numbered captures follow opening-parenthesis order, including named capturing groups. Non-capturing groups do not receive a number.

const date = /(?<year>[0-9]{4})-(?<month>[0-9]{2})-(?<day>[0-9]{2})/;

"2026-07-12".replace(date, "$<day>/$<month>/$<year>");
// "12/07/2026"

Global and sticky regexes carry state

With g or y, built-in execution uses the RegExp object's mutable lastIndex. A successful match sets it to the end of the match. Failure resets it to zero. Reusing the same object can therefore make repeated test() calls alternate between success and failure.

const re = /cat/g;

re.test("cat"); // true, lastIndex is 3
re.test("cat"); // false, lastIndex is 0
re.test("cat"); // true, lastIndex is 3

g may search at lastIndex or later. y accepts a match only if it begins exactly there, which is useful when a parser must reject gaps between tokens.

const token = /[A-Za-z]+/y;
const input = "name = value";

token.lastIndex = 0;
token.exec(input)?.[0]; // "name"

token.lastIndex = 5;
token.exec(input); // null because index 5 contains "="

Unicode mode changes the unit of matching

Without u, a JavaScript regex interprets the input as UTF-16 code units. With u, surrogate pairs are interpreted as one code point, Unicode code point escapes and property escapes are enabled, and legacy ambiguous syntax becomes stricter.

"😄".match(/./g)?.length;  // 2 UTF-16 code units
"😄".match(/./gu)?.length; // 1 Unicode code point

/^\p{Letter}+$/u.test("Λόγος"); // true

The s flag and u solve different problems. s adds line terminators to dot's set. u lets dot consume an astral code point as one character. /./su combines both behaviors.

[ senior depth ]

Why catastrophic backtracking happens

V8's usual regular expression engine is a backtracking engine. When a pattern offers several ways to continue, the engine tries one path and may return to an earlier choice after a later failure. Ambiguous nested quantifiers can create exponentially many paths through the same input.

const vulnerable = /^(a+)+$/;

vulnerable.test("aaaaaaaaaaaaaaaaaaaa!");

The inner a+ and outer + can partition the run of a characters in many ways. The final ! makes the anchored match fail only after those choices matter. A remote caller who controls the input can spend enough CPU in one match to delay other work. In a Node.js server, this is regular expression denial of service, or ReDoS, because the match runs on the event loop.

Greedy versus lazy changes which path is attempted first. It does not remove the ambiguous paths.

const stillVulnerable = /^(a+?)+$/;

Wrong "Adding anchors or making the inner quantifier lazy guarantees linear time."

Right Remove the overlapping choices, then bound untrusted input so its worst-case cost is limited.

Rewrite the language, not the search order

For the rule "one or more ASCII a characters," the nested group adds no expressive power. One quantifier describes the same accepted strings.

function isAllowed(value) {
  if (value.length === 0 || value.length > 256) return false;
  return /^a+$/.test(value);
}

Node.js guidance calls out three risk shapes: nested quantifiers such as (a+)*, overlapping alternatives such as (a|a)*, and backreferences. Backreferences are useful because they match text equality, but Node.js notes that no regular expression engine can guarantee linear evaluation for them. Use indexOf() for a simple substring search.

function containsInvoice(value) {
  if (value.length > 10_000) return false;
  return value.indexOf("invoice") !== -1;
}

Bounding input is defense in depth. It makes the worst accepted input measurable, but it does not turn a highly ambiguous pattern into a good one. Test long near-misses as well as successful inputs, because V8 and Node.js both document that a late mismatch exposes the expensive search.

Assertions and backreferences affect engine defenses

V8's 2021 article introduced an additional non-backtracking engine in V8 8.8. In the design documented there, automatic fallback required experimental runtime flags and could not handle every pattern. Its exclusions included backreferences, lookarounds, large or deeply nested finite repetitions, and patterns using u or i.

// Application code should remain safe without experimental V8 flags.
const pair = /^(?<left>[A-Za-z]+):\k<left>$/;
pair.test("same:same"); // true

Do not treat an engine-specific fallback as the security boundary. If a pattern with a backreference or lookaround processes attacker-controlled text, review its ambiguity and impose a length limit at the application boundary.

Lookbehind runs its atoms backwards

JavaScript permits variable-length lookbehind. The engine knows where the assertion ends, so it matches the atoms inside it in reverse order. Greedy captures can therefore look surprising.

/(?<=([ab]+)([bc]+))$/.exec("abc");
// ["", "a", "bc"]

[bc]+ is encountered first during backward matching and captures "bc"; [ab]+ receives "a". Alternatives inside the lookbehind are still attempted from left to right. This behavior is part of JavaScript's documented regex semantics, not a reason to assume that every regex flavor accepts the same lookbehind patterns.

dig deeper

Primary sources behind these notes - the specs and official docs worth reading in full.

beta

The interviewer part is in the works.

The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.

more JavaScript

was this useful?