Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

JavaScript (ES2026)  Module 7 – Strings, Unicode and regular expressions

Modern regex features and RegExp.escape

Named groups, lookbehind, match indices, the v flag, inline modifiers and RegExp.escape in JavaScript, and how to keep patterns safe from ReDoS.

  • Advanced
  • 25 minutes
  • Examples run with Node.js 24.21.0 and quickjs 0.32.0
  • By MySmartCoPilot
Free lesson, Pro answers: 25 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Use named groups, backreferences, lookahead, lookbehind and match indices
  • Use the v flag's set operations and properties of strings
  • Apply inline modifiers and duplicate group names where a pattern needs them
  • Build patterns from user input safely with RegExp.escape and avoid catastrophic backtracking (ReDoS)

Before you start

What you’ll learn · Before you start

What you will learn

  • Use named groups, backreferences, lookahead, lookbehind and match indices
  • Use the v flag's set operations and properties of strings
  • Apply inline modifiers and duplicate group names where a pattern needs them
  • Build patterns from user input safely with RegExp.escape and avoid catastrophic backtracking (ReDoS)

Before you start

On this page

Modern JavaScript regular expressions can name their groups, look behind as well as ahead, report where every group starts and ends, combine Unicode character sets, switch case rules for part of a pattern and reuse one group name in two alternatives. RegExp.escape(text), added in ECMAScript 2025, turns any string into a pattern that matches exactly that string, which makes it safe to build a regex from something a person typed. Behind the last point lies a real risk: some patterns take exponential time on input that almost matches, and an attacker only needs one such pattern and one crafted string to stall a server. This lesson shows each feature with an example that runs, the edition that added it and where it works, and then how to recognise and rewrite the patterns that backtrack catastrophically.

What the recent editions added

Feature Syntax Added in
Named groups (?<name>…), \k<name> ES2018
Lookbehind (?<=…), (?<!…) ES2018
Match indices the d flag, indices ES2022
Set notation the v flag: --, &&, \q{…}, \p{RGI_Emoji} ES2024
Duplicate names one (?<name>…) in two alternatives ES2025
Inline modifiers (?i:…), (?-i:…) ES2025
Escaping text RegExp.escape(text) ES2025

ES2018 is short for ECMAScript 2018: the last column names the edition of the standard that added each feature.

The Run buttons on this page use QuickJS, which supports every row; Node.js 24 does too. The section on support near the end lists the first browser versions.

Named groups, backreferences and lookaround

A named group, (?<word>…), captures like a numbered group and can be read as groups.word or reused inside the same pattern as \k<word>. Lookaround assertions check the text around a position without consuming it, so whatever they look at is not part of the match: (?=…) requires something to follow, (?!…) forbids it, and (?<=…) and (?<!…) do the same for what comes before.

A doubled word, digit grouping, a file-name rule and a bill JavaScript · lookaround.mjs
// A named group, and a backreference to it: a word typed twice in a row.
const doubled = /\b(?<word>\w+) \k<word>\b/gi;
console.log(JSON.stringify('The the train is is late'.match(doubled)));

// Lookahead (?=…) tests what follows without using it up: every position followed by groups of three digits.
console.log('1234567'.replace(/\B(?=(\d{3})+$)/g, ','));

// Negative lookahead (?!…): a module file that ends in .mjs, but not a test file.
const moduleFile = /^(?!.*\.test\.mjs$).+\.mjs$/;
console.log(['app.mjs', 'app.test.mjs', 'app.js'].map((f) => `${f}: ${moduleFile.test(f)}`).join(', '));

// Lookbehind (?<=…) and negative lookbehind (?<!…) test what comes before.
const bill = 'Tea ₹15, samosa ₹20, 2 plates, refund -₹5';
console.log(JSON.stringify(bill.match(/(?<=₹)\d+/g))); // numbers right after the rupee sign
console.log(JSON.stringify(bill.match(/(?<![₹\d])\d+/g))); // numbers with no rupee sign in front
console.log(JSON.stringify(bill.match(/(?<=-₹)\d+/g))); // only the refund

Output

["The the","is is"]
1,234,567
app.mjs: true, app.test.mjs: false, app.js: false
["15","20","5"]
["2"]
["5"]

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node lookaround.mjs

The doubled-word pattern finds “The the” because the i flag also applies to the backreference. The grouping pattern matches no characters at all, only positions: each position inside the number that is followed by a whole number of three-digit groups up to the end gets a comma. The file-name rule starts with a negative lookahead that reads ahead over the whole name and fails the match if it ends in .test.mjs; then .+\.mjs$ checks the ending from position 0 again. The three lookbehind lines read the same bill three ways: the numbers right after the rupee sign, the number with no sign in front of it, and only the refund, where the lookbehind is two characters long. JavaScript accepts any pattern inside a lookbehind, even one of varying length such as (?<=\$\d*); other engines are stricter, and Python’s re module, for one, requires a lookbehind to match a fixed number of characters.

Positions of every group: the d flag

The d flag adds an indices array to every result: a [start, end] pair for the whole match and for each group, and indices.groups for the named ones. That is what an editor needs to underline a value, or a linter to point at the column of a mistake, without searching the line a second time.

Start and end of every value in a settings line JavaScript · match-indices.mjs
// The d flag adds indices: where the whole match and every group start and end.
const SETTING = /(?<key>[a-z]+)\s*=\s*(?<value>[^;]*)/dg;
const line = 'timeout = 30; retries=x3; mode = fast';

for (const m of line.matchAll(SETTING)) {
  const [start, end] = m.indices.groups.value;
  console.log(`${m.groups.key}: ${JSON.stringify(m.groups.value)} from ${start} to ${end}`);
}

// Underline a bad value under the line, the way an editor marks an error.
const bad = [...line.matchAll(SETTING)].find((m) => !/^\d+$/.test(m.groups.value) && m.groups.key !== 'mode');
const [from, to] = bad.indices.groups.value;
console.log(line);
console.log(`${' '.repeat(from)}${'^'.repeat(to - from)} ${bad.groups.key} must be a number`);

Output

timeout: "30" from 10 to 12
retries: "x3" from 22 to 24
mode: "fast" from 33 to 37
timeout = 30; retries=x3; mode = fast
                      ^^ retries must be a number

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node match-indices.mjs

The end is exclusive, like the end argument of slice, so line.slice(start, end) gives the group’s text back. A group that took no part in the match has undefined in place of its pair. The positions are computed only when the flag asks for them, for performance reasons, so leave it off a regex whose indices nobody reads.

One group name in two alternatives

Since ECMAScript 2025 the same name may appear in different alternatives of a pattern. Only one alternative can match, so the name always holds one value, and the code that reads the result needs only one property for each field, whichever form the text used.

An amount written before or after its currency JavaScript · duplicate-groups.mjs
// One group name in two alternatives: an amount written before or after its currency.
const AMOUNT = /(?<currency>[₹$€])\s?(?<value>\d+(?:\.\d{1,2})?)|(?<value>\d+(?:\.\d{1,2})?)\s?(?<currency>INR|USD|EUR)/g;
const expenses = 'Paid ₹250 for the cab, 40 USD for the hotel, €12.50 for lunch and 99 INR for chai';

for (const { groups } of expenses.matchAll(AMOUNT)) {
  console.log(groups.currency.padEnd(3), groups.value);
}

// Inside one alternative a name must still be unique.
try {
  new RegExp('(?<part>a)(?<part>b)');
} catch (e) {
  console.log('the same name twice in one alternative:', e.name);
}

Output

₹   250
USD 40
€   12.50
INR 99
the same name twice in one alternative: SyntaxError

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node duplicate-groups.mjs

Without this feature the second alternative would need its own names, such as currency2 and value2, and every reader of the result would have to check both. The rule that remains is the one the last line shows: within a single alternative, where both groups could take part in the same match, a repeated name is still a SyntaxError.

Case rules for part of a pattern: inline modifiers

(?i:…) switches case-insensitive matching on inside the group only, and (?-i:…) switches it off inside the group when the regex has the i flag. The same works for m and s, and the two can be combined, as in (?i-s:…). No other flag can be switched this way, and JavaScript has no form without a group, such as the (?i) that some other regex engines accept at the start of a pattern.

Case-insensitive host, case-sensitive path JavaScript · modifiers.mjs
// (?i:…) turns on case-insensitive matching inside the group only. In a URL, the scheme and the host name
// ignore case, while the path does not.
const GUIDE = /^(?i:https:\/\/docs\.example\.com)\/Guide\/Intro$/;
for (const url of ['https://docs.example.com/Guide/Intro', 'HTTPS://Docs.Example.COM/Guide/Intro', 'https://docs.example.com/guide/intro']) {
  console.log(GUIDE.test(url), url);
}

// (?-i:…) turns a flag off inside the group: the whole pattern ignores case except the prefix.
const TICKET = /^(?-i:TKT)-[a-z]+-\d+$/i;
console.log(['TKT-pune-42', 'TKT-PUNE-42', 'tkt-pune-42'].map((t) => `${t}: ${TICKET.test(t)}`).join(', '));

// Only i, m and s can be switched inside a group.
try {
  new RegExp('(?g:a)');
} catch (e) {
  console.log('(?g:…):', e.name);
}

Output

true https://docs.example.com/Guide/Intro
true HTTPS://Docs.Example.COM/Guide/Intro
false https://docs.example.com/guide/intro
TKT-pune-42: true, TKT-PUNE-42: true, tkt-pune-42: false
(?g:…): SyntaxError

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node modifiers.mjs

The address example follows a rule of the URI standard: the scheme and the host name may be written in any case, while the path is case-sensitive, so /Guide/Intro and /guide/intro can be different pages. Without modifiers, one pattern has to spell out every letter of the host as a pair, [hH][tT][tT][pP]…, or the code has to test the parts separately. In real code, a URL is better checked with new URL(), which normalises it for you; the pattern here shows the mechanism.

The v flag: set operations and properties of strings

The v flag, from ECMAScript 2024, is the u flag with more power inside character classes. A class can subtract one set from another with --, keep only the characters two sets share with &&, nest classes, and contain whole strings written as \q{…}. It also allows properties of strings, such as \p{RGI_Emoji}, which match a sequence of code points as one unit.

Devanagari without digits, Devanagari digits only, emoji and prefixes JavaScript · v-flag-sets.mjs
// Difference (--): Devanagari characters, but not the Devanagari digits.
const DEVANAGARI_WORD = /^[\p{Script=Devanagari}--\p{Nd}]+$/v;
for (const word of ['नमस्ते', 'भारत', 'कक्षा१०', 'Namaste']) {
  console.log(word, DEVANAGARI_WORD.test(word));
}

// Intersection (&&): characters that are both Devanagari and decimal digits.
const DEVANAGARI_DIGITS = /^[\p{Script=Devanagari}&&\p{Nd}]+$/v;
console.log('१२३', DEVANAGARI_DIGITS.test('१२३'), '| 123', DEVANAGARI_DIGITS.test('123'));

// A property of strings: an emoji made of several code points still counts as one.
for (const emoji of ['👍', '🇮🇳', '👩🏽‍💻']) {
  console.log(emoji, `${emoji.length} code units`, /^\p{RGI_Emoji}$/v.test(emoji), /^\p{Emoji}$/u.test(emoji));
}

// \q{…} puts whole strings into a class, next to single characters.
const PREFIX = /^[\q{INR|Rs}₹]\s?\d+$/v;
console.log(['Rs 40', '₹40', 'INR 40', 'R 40'].map((s) => `${s}: ${PREFIX.test(s)}`).join(', '));

Output

नमस्ते true
भारत true
कक्षा१० false
Namaste false
१२३ true | 123 false
👍 2 code units true true
🇮🇳 4 code units true false
👩🏽‍💻 7 code units true false
Rs 40: true, ₹40: true, INR 40: true, R 40: false

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node v-flag-sets.mjs

[\p{Script=Devanagari}--\p{Nd}] is “any character of the Devanagari script, minus the decimal digits”, which accepts नमस्ते and भारत and rejects a word that ends in the Devanagari digits १०. A simpler idea, \p{L} for letters, would fail here: the vowel signs and the virama in नमस्ते are combining marks (\p{M}), not letters, so /^\p{L}+$/u rejects the word. The intersection does the opposite: [\p{Script=Devanagari}&&\p{Nd}] keeps only the characters that are both Devanagari and digits, so १२३ passes and the ASCII digits 123 do not. In the emoji lines, the flag of India is two code points and the technologist emoji four, yet \p{RGI_Emoji} accepts each as one emoji, while the older \p{Emoji} matches single code points only. \q{INR|Rs} lets a class offer two whole words next to the single rupee sign.

The flag is also stricter. Inside a class, characters such as (, ), [, {, /, - and | must be escaped, and doubled punctuation such as && or !! is reserved. A property of strings cannot be negated, so \P{RGI_Emoji} and [^\p{RGI_Emoji}] both throw a SyntaxError. u and v cannot be used together; for new code, choose v.

Unicode Character Inspector See the code points, scripts and categories of a word, including the combining marks that letter-only patterns miss.

Building a pattern from user input: RegExp.escape

When a search term goes straight into new RegExp, its characters become pattern syntax. A term like c++ is a SyntaxError, a dot matches any character, and brackets turn into a group. RegExp.escape returns a version of the text in which everything that could have a meaning in a pattern is escaped, so the regex built from it matches the text literally.

Highlighting a search term, unsafe and escaped JavaScript · escape-search.mjs
const notes = 'C++ and c++ both run here. Version 1.5 is in the box, 1x5 is not. Price: 400 (approx).';

// Unsafe: the search term becomes pattern syntax.
function highlightUnsafe(text, term) {
  return text.replace(new RegExp(term, 'gi'), (found) => `[${found}]`);
}

// Safe: RegExp.escape turns every special character of the term into an escape.
function highlight(text, term) {
  if (term === '') return text;
  return text.replace(new RegExp(RegExp.escape(term), 'gi'), (found) => `[${found}]`);
}

for (const term of ['c++', '1.5', '(approx)']) {
  try {
    console.log(`unsafe ${term}: ${highlightUnsafe(notes, term)}`);
  } catch (e) {
    console.log(`unsafe ${term}: ${e.name}`);
  }
  console.log(`escaped ${term}: ${highlight(notes, term)}`);
}

// What the escaped text looks like: a leading digit or letter, punctuation and spaces become \x escapes.
console.log(RegExp.escape('1.5'), '|', RegExp.escape('a-b'), '|', RegExp.escape('(approx) 400'));

Output

unsafe c++: SyntaxError
escaped c++: [C++] and [c++] both run here. Version 1.5 is in the box, 1x5 is not. Price: 400 (approx).
unsafe 1.5: C++ and c++ both run here. Version [1.5] is in the box, [1x5] is not. Price: 400 (approx).
escaped 1.5: C++ and c++ both run here. Version [1.5] is in the box, 1x5 is not. Price: 400 (approx).
unsafe (approx): C++ and c++ both run here. Version 1.5 is in the box, 1x5 is not. Price: 400 ([approx]).
escaped (approx): C++ and c++ both run here. Version 1.5 is in the box, 1x5 is not. Price: 400 [(approx)].
\x31\.5 | \x61\x2db | \(approx\)\x20400

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node escape-search.mjs

The unsafe version fails in three different ways: c++ cannot be compiled, 1.5 also highlights 1x5, and (approx) highlights the word without its brackets. The escaped version gets all three right. The escaped text looks odd but is designed to be safe wherever it is placed in a pattern. Syntax characters such as . and ( get a backslash. Other punctuation, white space and a leading digit or ASCII letter become \x codes, so the result can sit after an escape such as \1 or inside a v-mode class without being read as part of something else. Characters that mean nothing in a pattern, such as é or an emoji, are left alone. Two habits complete the picture: escape only the parts that came from outside, never the pattern you wrote, and handle the empty term yourself, because an empty regex matches at every position.

When the job is only to find plain text, string methods such as includes, indexOf and replaceAll with a string need no regex at all.

Catastrophic backtracking (ReDoS)

JavaScript’s regex engines backtrack: when one way of matching fails, they go back and try the next. Usually there are few ways and the search ends quickly. A quantifier inside another quantifier, over characters both can match, creates a very large number of ways. On an input that matches almost to the end and then fails, the engine tries every one of them before it can answer false. The OWASP page on regular expression denial of service calls such patterns “evil” and lists (a+)+, ([a-zA-Z]+)*, (a|aa)+ and (a|a?)+ among them.

/^(a+)+$/ tries all 4 ways to split "aaa" into groups before failing at "!"; with 30 letters there are 536,870,912 ways./^(a+)+$/ against "aaa!"Every way to split the a’s into groups is tried,and each one fails at the "!"aaa!(aaa)failsaaa!(aa)(a)failsaaa!(a)(aa)failsaaa!(a)(a)(a)failsa’s before the "!"ways to split them341051220524,28830536,870,912Each extra letter doubles the work. /^a+$/ hasonly one way to match, so it fails at once.

Why a quantifier inside a quantifier explodes on an input that almost matches

Text description of the diagram

The pattern /^(a+)+$/ is tried against the text "aaa!". Four rows show the four ways the engine can divide the three letters a into groups for the inner a+, each group drawn in its own colour and separated by a bar:

  • (aaa): one group of three letters;
  • (aa)(a): a group of two, then a group of one;
  • (a)(aa): a group of one, then a group of two;
  • (a)(a)(a): three groups of one.

Every row ends at the "!", where $ cannot match, so every row fails, and the engine backtracks to try the next split before it can report that there is no match.

A table below gives the number of ways to split a run of letters: 4 for 3 letters, 512 for 10, 524,288 for 20 and 536,870,912 for 30. It is 2 to the power of one less than the number of letters, so each extra letter doubles the work. The note at the bottom says that /^a+$/ has only one way to match a run of letters, so it fails at once.

A run of n letters a can be divided between the inner a+ and the outer + in 2n−1 ways: 4 ways for 3 letters, 524,288 for 20 and 536,870,912 for 30. The timings below show the doubling on a real engine.

An exponential pattern and a slug check, timed JavaScript · redos-timing.mjs
// Catastrophic backtracking: a quantifier inside a quantifier over the same characters,
// and an input that almost matches. Every extra character doubles the work.
const nested = /^(a+)+$/;
const slugSlow = /^([a-z0-9]+-?)+$/; // a slug check written the risky way
const slugFast = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; // the same rule, with only one way to match

// The fastest of five runs, so that a busy computer disturbs the numbers less.
function timed(re, input) {
  let best = Infinity;
  let result;
  for (let i = 0; i < 5; i++) {
    const start = performance.now();
    result = re.test(input);
    best = Math.min(best, performance.now() - start);
  }
  return `${result} after ${best.toFixed(1)} ms`;
}

for (const n of [16, 18, 20, 22, 24]) {
  console.log(`${n} letters and a "!": nested ${timed(nested, 'a'.repeat(n) + '!')}`);
}
console.log(`slow slug check, 22 letters and a "!": ${timed(slugSlow, 'a'.repeat(22) + '!')}`);
console.log(`fast slug check, 22 letters and a "!": ${timed(slugFast, 'a'.repeat(22) + '!')}`);
console.log(`fast slug check, 100,000 letters and a "!": ${timed(slugFast, 'a'.repeat(100_000) + '!')}`);

Output

16 letters and a "!": nested false after 0.5 ms
18 letters and a "!": nested false after 1.9 ms
20 letters and a "!": nested false after 6.9 ms
22 letters and a "!": nested false after 69.3 ms
24 letters and a "!": nested false after 214.4 ms
slow slug check, 22 letters and a "!": false after 58.8 ms
fast slug check, 22 letters and a "!": false after 0.0 ms
fast slug check, 100,000 letters and a "!": false after 0.3 ms

This output changes from run to run: the times depend on the computer and on what else it is doing; the shape stays: every two extra letters multiply the nested pattern's time by about four

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node redos-timing.mjs

Every two extra letters multiply the time of /^(a+)+$/ by about four, so ten more letters cost about a thousand times more (210 is 1,024): a request of a few dozen characters can keep a server busy for minutes. The slug check shows that such patterns appear in real code. ([a-z0-9]+-?)+ was meant to say “letters and digits with optional hyphens”, but because the hyphen is optional, a run of letters can again be divided in many ways. The rewrite [a-z0-9]+(?:-[a-z0-9]+)* says the same thing with exactly one way to match any text, and it rejects 100,000 letters followed by a ! in well under a millisecond.

Polynomial cases are slower to notice but just as real. /\s+$/, used to find spaces at the end of a text, starts a match at every space of a long run that is followed by another character, and reads to the end of the run each time; on a run of 50,000 spaces that is over a billion steps. The exercise below contains one.

How to keep patterns safe:

  • Give every character one way to match. Avoid a repeated group whose inside can match the same text in several ways: nested quantifiers, optional separators inside a repetition, or alternatives that overlap, such as (a|a?).
  • Let a match start in one place. A lookbehind such as (?<![ \t]) in front of [ \t]+$ rejects every start position inside a run of spaces, which turns the quadratic search into a linear one.
  • Bound the input first. A length check before the pattern runs caps the cost of a quadratic pattern such as /\s+$/. It does not save an exponential one, which is dangerous at a few dozen characters, so rewrite those.
  • Never run a pattern written by a user in the main thread of a server. Escape the user’s text with RegExp.escape, or, when users really must write patterns, run them in a worker that is stopped after a time limit, or with a linear-time engine such as RE2, which offers no backreferences or lookarounds in exchange for its guarantee.
  • Test with hostile input: a long run of the characters a pattern repeats, followed by one character that makes it fail.

Support in browsers and Node.js

Version note

These are the first versions that support each feature, from the compatibility data of MDN Web Docs. Node.js 24 runs every example of this lesson, and the QuickJS runner of the Run buttons supports them all.

  • Named groups: Chrome 64, Firefox 78, Safari 11.1, Node.js 10.
  • Lookbehind: Chrome 62, Firefox 78, Safari 16.4, Node.js 8.10.
  • Match indices (d): Chrome 90, Firefox 88, Safari 15, Node.js 16.
  • The v flag: Chrome 112, Firefox 116, Safari 17, Node.js 20.
  • Duplicate named groups: Chrome 125, Firefox 129, Safari 17, Node.js 23.
  • Inline modifiers: Chrome 125, Firefox 132, Safari 26, Node.js 23.
  • RegExp.escape: Chrome 136, Firefox 134, Safari 18.2, Node.js 24.

A pattern that uses a newer feature is a SyntaxError on an older engine as soon as the script is parsed, if it is written as a literal; the whole script then fails to load. When code must still run on older browsers, build such patterns with new RegExp inside a try block and fall back to a simpler pattern, or check typeof RegExp.escape before calling it.

Regex Tester Try the patterns of this lesson on your own text; the tester stops a pattern that runs too long instead of freezing the page.

Exercise

Exercise · Medium · JavaScript

Make three slow or unsafe patterns fast and correct

patterns.mjs has three functions that work on friendly input and fail on hostile input. Fix each one so that it returns the right answer for every test, and finishes in well under a second on inputs of tens of thousands of characters.

  1. isSlug(text) is true when text is one or more runs of small letters and digits, joined by single hyphens: regex-in-javascript and es2025 are slugs; Regex, a--b, -a, a- and the empty string are not. The current pattern nests one quantifier inside another, so a long run of letters followed by one wrong character makes it try every possible split before it fails.
  2. highlight(text, term) puts square brackets around every occurrence of term in text, ignoring case. The term is plain text typed by a person: c++, 1.5 and (approx) must be found literally, and an empty term returns text unchanged. The current version turns the term into pattern syntax.
  3. trimLineEnds(text) removes the spaces and tabs at the end of every line, and keeps everything else, line breaks included. On a line with a long run of spaces in the middle, the current pattern starts a match at every one of those spaces and reads to the end of the run each time.

Keep regular expressions where they are; the tests check the results and the time each call takes.

Starter code · patterns.mjs

/** Runs of small letters and digits joined by single hyphens. */
export function isSlug(text) {
  return /^([a-z0-9]+-?)+$/.test(text);
}

/** Every occurrence of term, ignoring case, wrapped in square brackets. */
export function highlight(text, term) {
  return text.replace(new RegExp(term, 'gi'), '[$&]');
}

/** The text without spaces or tabs at the ends of its lines. */
export function trimLineEnds(text) {
  return text.replace(/[ \t]+$/gm, '');
}
The sample tests · patterns.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { highlight, isSlug, trimLineEnds } from './patterns.mjs';

/** Calls fn and checks that it took less than 200 milliseconds. */
function quick(label, fn) {
  const start = Date.now();
  const result = fn();
  const ms = Date.now() - start;
  assert.ok(ms < 200, `${label} took ${ms} ms`);
  return result;
}

test('isSlug accepts slugs and rejects everything else', () => {
  for (const s of ['regex-in-javascript', 'es2025', 'a', 'a-b-c']) assert.equal(isSlug(s), true, s);
  for (const s of ['Regex', 'a--b', '-a', 'a-', '', 'a b', 'a_b']) assert.equal(isSlug(s), false, JSON.stringify(s));
});

test('highlight finds the term literally, in any case', () => {
  assert.equal(highlight('C++ and c++ both', 'c++'), '[C++] and [c++] both');
  assert.equal(highlight('1.5 is in the box, 1x5 is not', '1.5'), '[1.5] is in the box, 1x5 is not');
  assert.equal(highlight('Price: 400 (approx).', '(approx)'), 'Price: 400 [(approx)].');
  assert.equal(highlight('Costs $5 or $50', '$5'), 'Costs [$5] or [$5]0');
});

test('highlight returns the text unchanged for an empty term', () => {
  assert.equal(highlight('nothing to mark', ''), 'nothing to mark');
});

test('trimLineEnds removes spaces and tabs at line ends only', () => {
  assert.equal(trimLineEnds('a  \nb\t\n  \nc d '), 'a\nb\n\nc d');
  assert.equal(trimLineEnds('keep  inner  spaces'), 'keep  inner  spaces');
});

test('isSlug is quick on a long run of letters and one wrong character', () => {
  assert.equal(quick('isSlug', () => isSlug('a'.repeat(40) + '!')), false);
});

test('trimLineEnds is quick on a line with a long run of spaces in the middle', () => {
  const line = 'x' + ' '.repeat(50_000) + 'y';
  assert.equal(quick('trimLineEnds', () => trimLineEnds(line)), line);
});
A hint

Give every character only one way to be matched. For isSlug, write the rule as "a run of letters and digits, then any number of hyphen-and-run pairs", with no optional hyphen inside a repeated group. For highlight, escape the term before it goes into new RegExp. For trimLineEnds, let a match start only where a run of spaces and tabs starts: a negative lookbehind, (?<![ \t]), rejects every position that has a space or a tab just before it.

The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Check yourself

5 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 5 What does this print?

    Read the code, then choose one answer.

    console.log(RegExp.escape('1.5'));
    Show the answer to question 1

    Answer: \x31\.5

    The dot becomes \. because it is pattern syntax. The leading digit becomes \x31 so that the escaped text stays a plain digit even when it is placed right after an escape such as \1 or \0 in a larger pattern; 1\.5 written there could be read as a backreference.

  2. Question 2 of 5 What does this print?

    Read the code, then choose one answer.

    console.log(/(?<=\$)\d+/.exec('total $42 today')[0]);
    Show the answer to question 2

    Answer: 42

    A lookbehind checks what comes before the match without including it, so the match is only the digits after the dollar sign.

  3. Question 3 of 5 What is the value of /^(?i:abc)D$/.test('ABCd')?

    Choose one answer.

    Show the answer to question 3

    Answer: false

    (?i:…) ignores case only inside the group, so ABC matches abc, but the D after the group is still case-sensitive and does not match d.

  4. Question 4 of 5 Which patterns can take exponential time on a long input that almost matches?

    Choose every answer that is right.

    Show the answer to question 4

    Answer:

    • /^(a+)+$/
    • /^(\w+\s?)+$/

    The first two repeat a group whose inside can match the same characters in many ways (a+ inside +, and \w+ followed by an optional space inside +), so a failing input makes the engine try every split. In the third, each hyphen must be followed by a new run, so there is only one way to match; the fourth has fixed counts.

  5. Question 5 of 5 Which pattern accepts नमस्ते and rejects text that contains the Devanagari digits ०–९?

    Choose one answer.

    Show the answer to question 5

    Answer: /^[\p{Script=Devanagari}--\p{Nd}]+$/v

    With the v flag, -- subtracts the decimal digits from the Devanagari script. \p{L} alone rejects नमस्ते because its vowel signs and virama are combining marks, not letters; with the u flag the -- form is a SyntaxError; and \d matches only the digits 0 to 9, so [^\d] lets ० to ९ through.

Interview questions

What does RegExp.escape do, and when do you need it? RegExp.escape(text), added in ECMAScript 2025, returns a version of a string that a regular expression matches literally. It puts a backslash before syntax characters such as ., *, +, ( and [, and writes other punctuation, white space and a leading digit or letter as \x codes, so the result is safe in any position of a pattern, inside a v-mode class too. You need it whenever part of a pattern comes from outside the program: a search term, a file name, a word from a list in a database. Without it, a term like c++ throws a SyntaxError, 1.5 also matches 1x5, and a user can slip in a pattern that backtracks for minutes. It throws a TypeError for anything that is not a string.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    A product search endpoint in Node.js builds new RegExp(query, 'i') from what users type and tests it against product names. One request keeps the server at 100 % CPU, and every other request times out. Explain what happened, how you would confirm it, and how you would redesign the search.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Fresher to mid

    A sign-up form checks names with /^[a-zA-Z ]{2,40}$/ and rejects people who type their names in Devanagari or Tamil, or with accents, as in Zoë. Rewrite the check with Unicode-aware regex features, and say what a name check should and should not try to enforce.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • Named groups ((?<name>…), \k<name>) and lookaround ((?=…), (?!…), (?<=…), (?<!…)) make patterns readable and let you test context without consuming it; lookbehinds may have any length.
  • The d flag gives the start and end of every group in indices, with an exclusive end, like slice.
  • Since ECMAScript 2025, one group name may appear in several alternatives, and (?i:…) or (?-i:…) change i, m or s for part of a pattern.
  • The v flag adds --, &&, nested classes, \q{…} and properties of strings such as \p{RGI_Emoji}; letters of scripts such as Devanagari need their combining marks, so \p{L} alone is not enough.
  • Escape outside text with RegExp.escape before it goes into new RegExp, and handle an empty term yourself.
  • Nested or overlapping quantifiers can take exponential time; rewrite them so every character has one way to match, bound the input length, and never run a user’s pattern unguarded.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.