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.
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 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
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
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.
// 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
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
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.
// 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
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
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.
// (?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
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
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.
// 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
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
[\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.
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.
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
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
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.
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.
// 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
vflag: 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.
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.
isSlug(text)istruewhentextis one or more runs of small letters and digits, joined by single hyphens:regex-in-javascriptandes2025are 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.highlight(text, term)puts square brackets around every occurrence oftermintext, ignoring case. The term is plain text typed by a person:c++,1.5and(approx)must be found literally, and an empty term returnstextunchanged. The current version turns the term into pattern syntax.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.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
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.
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.
-
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.
-
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.
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
dflag gives the start and end of every group inindices, with an exclusive end, likeslice. - Since ECMAScript 2025, one group name may appear in several alternatives, and
(?i:…)or(?-i:…)changei,morsfor part of a pattern. - The
vflag 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.escapebefore it goes intonew 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
- ECMAScript 2026 Language Specification: RegExp.escape (Ecma International)
- ECMAScript 2026 Language Specification: Patterns (Ecma International)
- ECMAScript 2026 Language Specification: Patterns, Static Semantics: Early Errors (Ecma International)
- ECMAScript 2026 Language Specification: UpdateModifiers (Ecma International)
- ECMAScript 2026 Language Specification: Binary Unicode properties of strings (Ecma International)
- ECMAScript 2026 Language Specification: Regular Expression Flags (Ecma International)
- RegExp v flag with set notation and properties of strings (proposal) (Ecma International (TC39))
- Regular Expression Pattern Modifiers (proposal) (Ecma International (TC39))
- Duplicate named capturing groups (proposal) (Ecma International (TC39))
- RegExp Escaping (proposal) (Ecma International (TC39))
- RegExp Match Indices (proposal) (Ecma International (TC39))
- re: Regular expression operations (lookbehind assertions) (Python Software Foundation)
- TC39 finished proposals (Ecma International (TC39))
- RegExp.escape() (MDN Web Docs)
- Modifier: (?ims-ims:...) (MDN Web Docs)
- Lookbehind assertion: (?<=...), (?<!...) (MDN Web Docs)
- Named capturing group: (?<name>...) (MDN Web Docs)
- RegExp.prototype.unicodeSets (MDN Web Docs)
- RegExp.prototype.hasIndices (MDN Web Docs)
- Regular expression Denial of Service (ReDoS) (OWASP Foundation)
- RE2, a regular expression library (Google)
- RFC 3986: Uniform Resource Identifier (URI), Generic Syntax (section 6.2.2.1, case normalization) (IETF)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress