We use cookies to enhance your experience on the site
CodeWorlds

Regular Expressions - Groups, Lookahead and Named Captures

Down in the Jurassic Park control room the security system writes a fresh line to disk every few seconds. Fence voltages, gate sensors, feeding timers, vehicle transponders - by the end of a single shift there are tens of thousands of entries, and buried somewhere in that pile is the one line that explains why paddock nine went quiet. Nobody is going to scroll through it by hand. What we need is a way to describe the shape of the thing we are hunting for, hand that description to JavaScript, and let the engine do the searching for us.

That description is a regular expression. Instead of asking "does this string contain the exact characters RAPTOR-001", a regular expression lets us ask "does this string contain a run of capital letters, a hyphen and three digits" - and then pull those digits back out. Think of it as search DNA: a compact sequence that describes a whole family of matching strings rather than one specific string. In this lesson we build up from the two ways of creating a pattern, through the methods that run it, all the way to capturing groups, named captures and lookahead.

Two Ways to Build a Pattern

JavaScript gives you exactly two ways to create a regular expression, and both are worth knowing because they are good at different things. The first is the literal, written

/pattern/
or
/pattern/flags
: you put the pattern straight into your source code between two forward slashes, the same way you would write a number or a string. The engine compiles it once, while the file is being parsed, which makes it the fastest and by far the most readable option for any pattern you already know at the moment you are writing the code. The second is the RegExp constructor,
new RegExp("pattern", "flags")
, which takes the pattern as an ordinary string. That extra layer of quoting is a nuisance, but it buys you something the literal simply cannot offer: the pattern can be assembled at runtime out of variables, user input or configuration values.

1// Way one - a literal: the pattern lives between two slashes
2const raptorId = /RAPTOR-\d+/;
3console.log(raptorId.test("RAPTOR-001")); // true
4console.log(raptorId.test("TREX-001"));   // false
5
6// Way two - the RegExp constructor: the pattern is an ordinary string
7const species = new RegExp("Velociraptor", "i"); // i = ignore letter case
8console.log(species.test("velociraptor")); // true
9
10// The constructor earns its keep when the pattern is assembled at runtime
11const wanted = "Triceratops";
12const dynamic = new RegExp(wanted + "-\\d{3}");
13console.log(dynamic.test("Triceratops-042")); // true
14console.log(dynamic.test("Triceratops-42"));  // false

Watch the backslashes in that last example. Inside a literal you write

\d
and you are done, but inside a string every backslash has to be doubled, so the same pattern becomes
"\\d"
. Forgetting that doubling is the single most common way people break a constructor-built pattern.

Those two really are the whole list, and the alternatives that sound plausible are not alternatives at all.

JSON.parse()
and
JSON.stringify()
have nothing to do with patterns - they move data between JavaScript values and JSON text, and a regular expression does not even survive the trip, since
JSON.stringify(/abc/)
hands you back an empty object. There is no
document.regex()
and no
window.match()
either; those names borrow the browser globals
document
and
window
, but regular expressions belong to the JavaScript language itself and behave identically in Node, in a worker or in a browser tab. Finally,
String.regex()
and
Array.match()
do not exist. Strings do have
match
,
matchAll
,
replace
,
search
and
split
, but every one of those consumes a pattern you already built - none of them creates one - and arrays have no
match
method whatsoever.

What Each Method Hands Back

Once a pattern exists, the next question is which method to run it with, and the honest answer is: pick the one whose return value is the shape you actually want. This trips people up constantly, because the methods look interchangeable and are not. Some of them live on the regular expression itself, such as

test()
and
exec()
. Others live on the string, such as
match()
,
matchAll()
,
search()
,
replace()
and
split()
. Below is one park log line pushed through all of them, so you can see side by side exactly what comes out the other end. Read the comments as a lookup table - it is the table you will come back to most often.

1const entry = "2024-01-15 10:30:45 [RAPTOR-001] STATUS: escaped, SECTOR: B-4";
2
3// test() -> a boolean, and nothing else
4console.log(/STATUS: escaped/.test(entry)); // true
5console.log(/STATUS: calm/.test(entry));    // false
6
7// match() -> an array of matched fragments, or null when nothing matches
8console.log(entry.match(/\d{4}-\d{2}-\d{2}/)[0]); // "2024-01-15"
9
10// search() -> the index of the first match, or -1
11console.log(entry.search(/RAPTOR/)); // 21
12
13// replace() -> a brand new string; the original is never modified
14console.log(entry.replace(/RAPTOR-\d+/g, "[REDACTED]"));
15
16// matchAll() -> every match in turn, and it requires the g flag
17console.log([...entry.matchAll(/\d+/g)].map(m => m[0]));
18
19// split() -> the text cut apart wherever the pattern hits
20console.log(entry.split(/[\s,]+/));

Of the whole family,

test()
is the one to commit to memory first, because its return value is the simplest: a plain boolean. You get
true
when the pattern matches somewhere in the string and
false
when it does not, and that is the entire contract. It never hands you the matched text, never hands you an array, and never hands you a position - each of those jobs belongs to a different method entirely.

So keep the four apart. A new string with the matches swapped out is what

replace()
returns, and note that it returns a copy rather than editing the original, because strings in JavaScript are immutable. An array of matched fragments is what
match()
returns, or
null
when there is no hit at all, which is exactly why blindly reading
[0]
off a failed match throws. The index of the first match is what
search()
returns, giving you
-1
when the pattern is absent. And a straight yes-or-no verdict is
test()
. When all you need is "is this thing in there",
test()
is both the clearest and the cheapest choice.

Character Classes and Quantifiers

A pattern made of ordinary letters can only ever find that exact run of letters, which is barely better than

includes()
. The power arrives with metacharacters - the shorthands that stand for a whole category of character.
\d
matches any digit,
\w
matches a word character (a letter, a digit or an underscore), and
\s
matches whitespace, meaning spaces, tabs and newlines. Their uppercase twins invert the meaning:
\D
is anything that is not a digit,
\W
anything that is not a word character,
\S
anything that is not whitespace. Square brackets let you spell out a set of your own, a hyphen inside them describes a range, and a caret at the front of the brackets negates the whole set.

1// \d - a digit, \w - a word character, \s - whitespace
2console.log("Sector 7 - status OK".match(/\d/g)); // ["7"]
3console.log("DINO_001_REX".match(/\w+/g));        // ["DINO_001_REX"]
4console.log("Park  Jurassic".split(/\s+/));       // ["Park", "Jurassic"]
5
6// The uppercase versions mean the exact opposite
7console.log("Dino123".match(/\D/g)); // ["D", "i", "n", "o"]
8
9// Your own sets, ranges, and negation with ^ inside the brackets
10console.log("Tyrannosaurus".match(/[aeiou]/g)); // ["a", "o", "a", "u", "u"]
11console.log("Sector7A".match(/[^0-9]+/g));      // ["Sector", "A"]
12
13// Quantifiers control how many times the thing before them may repeat
14console.log(/ro*ar/.test("rar"));                   // true  - zero or more
15console.log(/ro+ar/.test("rar"));                   // false - one or more
16console.log(/colou?r/.test("color"));               // true  - zero or one
17console.log(/DINO-\d{3}/.test("DINO-001"));         // true  - exactly three
18console.log(/[A-Z]{2,4}-\d{3,5}/.test("REX-001"));  // true  - a range

Quantifiers answer the other half of the question: not what to match, but how many of it. A star means zero or more repetitions, so

/ro*ar/
happily accepts
rar
with no
o
at all. A plus demands at least one, which is why the same input fails against
/ro+ar/
. A question mark means zero or one, the classic way to accept both British and American spellings in a single pattern. Braces are the precise option:
{3}
means exactly three, and
{2,4}
means anywhere from two to four. Those braces are what turn a vague pattern into a real format check, because
/DINO-\d{3}/
accepts
DINO-001
and rejects
DINO-01
on the spot.

Anchors and Flags

By default a pattern is happy to match anywhere inside a string, which is fine for searching and dangerous for validating. Anchors fix that. A caret at the start of the pattern pins the match to the beginning of the text, a dollar sign at the end pins it to the end, and wrapping a pattern in both is how you say "the whole string must look exactly like this" rather than "this appears somewhere". A third anchor,

\b
, marks a word boundary - the invisible seam between a word character and a non-word character - which is how you find
Rex
as a standalone word without also matching it inside
Rexona
. Flags then modify how the whole pattern is applied, and they are written after the closing slash.

1// ^ pins the match to the start, $ pins it to the end
2console.log(/^ALERT/.test("ALERT: dinosaur escaped")); // true
3console.log(/^ALERT/.test("Status: ALERT"));           // false
4console.log(/OK$/.test("Status: OK"));                 // true
5
6// \b marks a word boundary
7console.log(/\bRex\b/.test("T-Rex"));  // true
8console.log(/\bRex\b/.test("Rexona")); // false
9
10// Flags sit after the closing slash
11const pattern = /ERROR/gi;
12const report = "Error in sector 4, ERROR in sector 9, error logged";
13console.log(report.match(pattern)); // ["Error", "ERROR", "error"]
14
15// m makes ^ and $ apply to every line, not just the whole text
16const board = "Sector 1: OK\nSector 2: ALERT\nSector 3: OK";
17console.log(board.match(/^Sector/gm).length); // 3
18
19// s lets the dot swallow newlines too
20console.log(/Start.End/.test("Start\nEnd"));  // false
21console.log(/Start.End/s.test("Start\nEnd")); // true

Look closely at the shape of that flagged literal, because it is worth being able to type from memory in one clean pass. It opens with the declaration,

const pattern
, then comes the assignment together with the opening slash,
= /
, then the body of the pattern itself,
ERROR
, and finally the closing slash carrying its flags,
/gi
. Put end to end that reads
const pattern = /ERROR/gi;
- declaration, assignment and opening delimiter, pattern body, closing delimiter with flags. Every regular expression literal you ever write follows that same running order.

The two flags in that example are the ones you will use most. The

g
flag is global: without it
match()
stops after the first hit, and with it you get every hit in the string, which is also what makes
matchAll()
legal. The
i
flag ignores letter case, so a single pattern catches
Error
,
ERROR
and
error
alike. Beyond those,
m
switches on multiline mode so that
^
and
$
bind to each line rather than to the text as a whole, and
s
puts the pattern in dotAll mode so that
.
will also match a newline. Flags stack freely, and their order between the closing slash and the semicolon makes no difference at all.

Capturing Groups and Named Captures

Finding a match is only half the job; usually we want the pieces. Round brackets create a capturing group, which tells the engine to remember whatever that portion of the pattern matched. The array returned by

match()
then carries the full match at index
0
and each group after it, in the order the opening brackets appear. That works, but counting brackets to figure out whether the sector code is group three or group four is a miserable way to spend an afternoon, and it breaks the moment somebody inserts a group in the middle. Named captures solve it: write
(?<name>...)
and the results arrive on a
groups
object, addressable by a word instead of a number.

1// Round brackets capture: index 0 is the whole hit, then group by group
2const datePattern = /(\d{4})-(\d{2})-(\d{2})/;
3const parts = "2024-06-15".match(datePattern);
4console.log(parts[0]); // "2024-06-15"
5console.log(parts[1]); // "2024"
6console.log(parts[3]); // "15"
7
8// (?<name>...) gives every group a readable label
9const idPattern = /(?<species>[A-Z]+)-(?<serial>\d{3})/;
10const { species, serial } = "RAPTOR-001".match(idPattern).groups;
11console.log(species, serial); // "RAPTOR" "001"
12
13// (?:...) groups without capturing - handy with the | alternative
14const dinoPattern = /^(?:T-Rex|Velociraptor|Triceratops)$/;
15console.log(dinoPattern.test("Velociraptor"));  // true
16console.log(dinoPattern.test("Brachiosaurus")); // false
17
18// Groups can be reused inside replace() as $1, $2 and so on
19console.log("2024-06-15".replace(datePattern, "$3/$2/$1")); // "15/06/2024"

There is a third bracket form worth knowing, and it is the one people forget. Sometimes you need brackets purely for structure - to group a set of alternatives behind a single

|
choice, or to attach a quantifier to several characters at once - but you have no interest in the captured text. Writing
(?:...)
gives you the grouping without the capture, which keeps the results array clean and saves the engine a little bookkeeping.

Groups also pay off in

replace()
. Inside the replacement string,
$1
refers to the first capturing group,
$2
to the second, and
$<name>
to a named one, which turns date reformatting and log redaction into one-liners. Combine that with the
|
alternative, which simply means "either this or that", and with anchors around the whole thing, and you can express surprisingly precise rules in a very small amount of text.

Lookahead and Lookbehind

The last big idea is the assertion: a condition the surrounding text must satisfy, checked without actually consuming any characters. A positive lookahead

(?=...)
says "the next thing must look like this", a negative lookahead
(?!...)
says "the next thing must not look like this", and
(?<=...)
and
(?<!...)
are the same two ideas pointed backwards. Because assertions consume nothing, you can stack several of them at the same position, each testing a different requirement against the rest of the string. That stacking trick is how a single pattern can enforce several independent rules at once, which is exactly what an access code check needs.

1// (?=...) positive lookahead - must be followed by, but does not consume
2const celsius = /\d+(?=C)/;
3console.log("Enclosure temperature: 32C".match(celsius)[0]); // "32"
4
5// (?!...) negative lookahead - must NOT be followed by
6const notFahrenheit = /\d+(?!\d|F)/;
7console.log("Readings: 30F 28C".match(notFahrenheit)[0]); // "28"
8
9// Stacked lookaheads enforce several rules at the same position
10const strongCode = /^(?=.*[A-Z])(?=.*\d)(?=.*[!@#]).{8,}$/;
11console.log(strongCode.test("Raptor1!")); // true
12console.log(strongCode.test("raptor12")); // false - no capital, no symbol
13
14// (?<=...) and (?<!...) look backwards instead
15const afterSector = /(?<=SECTOR: )[A-Z]-\d/;
16console.log("STATUS: escaped, SECTOR: B-4".match(afterSector)[0]); // "B-4"

Notice what the lookahead does to the result in the first example. The pattern

/\d+(?=C)/
matches
32
and not
32C
, because the
C
was only ever a condition, never part of the match. Lookbehind works the same way in the other direction:
/(?<=SECTOR: )[A-Z]-\d/
finds
B-4
while leaving the label
SECTOR:
out of the result, which saves you from slicing the prefix off afterwards. That property - matching by context without capturing the context - is what makes assertions so useful for extraction.

Reading the Park Logs

Time to put all of it together on the job this lesson opened with. A security log line carries a date, a timestamp, an error code and a sector number, always in the same order and always in the same format. That regularity is precisely what a regular expression feeds on. We describe the line once, name every field we care about, and from then on each raw string turns into a tidy object we can filter, count and report on. Notice that the pattern is defined a single time outside the function rather than rebuilt on every call - compiling once and reusing is both faster and easier to maintain, and it is the habit to form early.

1const logs = [
2  "2024-01-15 10:30:45 ERR-4471 SECTOR: B-4 STATUS: fence offline",
3  "2024-01-15 10:31:02 ERR-1180 SECTOR: A-7 STATUS: door unlocked",
4  "2024-01-16 08:02:11 INFO-0001 SECTOR: C-2 STATUS: routine sweep"
5];
6
7const linePattern =
8  /^(?<date>\d{4}-\d{2}-\d{2}) (?<time>\d{2}:\d{2}:\d{2}) (?<code>[A-Z]+-\d+) SECTOR: (?<sector>[A-Z]-\d)/;
9
10function parseLog(line) {
11  const found = line.match(linePattern);
12  return found ? found.groups : null;
13}
14
15const parsed = logs.map(parseLog);
16console.log(parsed[0].date, parsed[0].code, parsed[0].sector);
17// "2024-01-15" "ERR-4471" "B-4"
18
19// Real incidents only - INFO lines are not errors
20const incidents = parsed.filter(row => /^ERR-/.test(row.code));
21console.log(incidents.map(row => row.sector)); // ["B-4", "A-7"]
22
23// One sweep for every sector mentioned anywhere in the file
24const sectors = [...logs.join("\n").matchAll(/SECTOR: ([A-Z]-\d)/g)];
25console.log(sectors.map(m => m[1])); // ["B-4", "A-7", "C-2"]

Three different jobs, three different tools, and each one picked for what it returns. Naming the groups means

found.groups.sector
reads like English instead of like
found[4]
. Filtering the incidents uses
test()
, because there the only question is yes or no. Sweeping the whole file uses
matchAll()
with the
g
flag, because there we want every occurrence rather than the first. Guarding with
found ? found.groups : null
matters too: a malformed line makes
match()
return
null
, and reaching for
.groups
on
null
would take the whole report down.

Summary

Regular expressions are a compact language for describing the shape of text, and they earn their keep in five recurring situations. Matching answers whether text fits a pattern, and

test()
gives you that as a boolean. Validation pins a pattern down with
^
and
$
so the entire string has to conform, not merely part of it. Extraction pulls the interesting fragments out with
match()
and
matchAll()
, ideally through named groups such as
(?<sector>[A-Z]-\d)
. Transformation rewrites text with
replace()
, reusing captured groups as
$1
or
$<name>
. And splitting breaks a string on a pattern rather than on a fixed separator, using
split()
.

Keep the essentials close at hand. You build a pattern in exactly two ways: as a literal,

/pattern/
, giving you lines such as
const pattern = /ERROR/gi;
, or through the constructor,
new RegExp("ERROR", "gi")
.
\d
,
\w
and
\s
cover digits, word characters and whitespace, while quantifiers such as
+
,
?
and
{3}
say how many times. Brackets capture,
(?:...)
groups without capturing,
(?<name>...)
labels the result, and lookahead checks the neighbours without swallowing them.

One last piece of advice from the control room. Regular expressions are easy to write and hard to read, so keep each one as small as the job allows, give it a descriptive variable name, and leave a comment explaining what it is meant to catch. Test it against the awkward cases as well as the obvious ones - the empty string, the malformed line, the entry that is one digit too short. And when a plain

includes()
or
startsWith()
would answer the question just as well, use that instead. The best regular expression is often the one you decided not to write.

Go to CodeWorlds