Skip to main content
Adapted, not copied

Original notes for JavaScript360.org — topic order inspired by Eloquent JavaScript (4th ed.) by Marijn Haverbeke. See the full attribution note.

Chapter 9: Regular Expressions

Estimated time: ~45 minutes · Chapter type: Concept

What you'll learn

  • How to build and test regular expressions
  • Character classes, quantifiers, and capturing groups
  • Greedy vs. lazy matching, and a common backtracking pitfall
  • A worked example: parsing a simple config file

Creating a regular expression

A regular expression (regex) is a tiny, specialized language for describing a pattern of text, rather than one exact string. JavaScript has a literal syntax, slashes on either side:

const hasDigit = /[0-9]/;

or the RegExp constructor, useful when the pattern needs to be built dynamically from a string at runtime:

const hasDigit2 = new RegExp('[0-9]');

Testing for matches

/cat/.test('concatenate');   // true — "cat" appears inside the word
/cat/.test('dog'); // false

Sets of characters

Square brackets define a character class — match any one character from the set:

/[aeiou]/.test('sky');    // false — no vowels
/[^aeiou]/.test('sky'); // true — ^ inside [] negates the set: "any character NOT in this list"

Common classes have shorthand:

  • \d — any digit, same as [0-9]
  • \w — any "word" character: letters, digits, underscore
  • \s — any whitespace character
  • \D, \W, \S — the negated versions of each
  • . — any character except a newline
/\d{3}-\d{4}/.test('555-0132'); // true

Repeating parts of a pattern: quantifiers

  • * — zero or more
  • + — one or more
  • ? — zero or one (optional)
  • {n} — exactly n
  • {n,m} — between n and m
/\d{3}-\d{3,4}/.test('555-013');  // true
/colou?r/.test('color'); // true — the "u" is optional
/colou?r/.test('colour'); // also true

Grouping subexpressions and matches

Parentheses group part of a pattern (so a quantifier can apply to more than one character) and also capture whatever matched that group for later use:

const match = '2026-09-08'.match(/(\d{4})-(\d{2})-(\d{2})/);
match[0]; // "2026-09-08" — the full match
match[1]; // "2026" — first group
match[2]; // "09"
match[3]; // "08"

Named groups make the intent much clearer, especially with several captures:

const dateMatch = '2026-09-08'.match(/(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/);
dateMatch.groups.year; // "2026"
dateMatch.groups.month; // "09"

Boundaries and look-ahead

\b matches a word boundary — the (zero-width) transition between a word character and a non-word character — useful for matching whole words instead of substrings:

/\bcat\b/.test('concatenate'); // false — "cat" here isn't a whole word
/\bcat\b/.test('the cat sat'); // true

A look-ahead (?=...) asserts that something follows, without consuming it as part of the match — handy for password-strength-style checks:

const hasDigitAhead = /^(?=.*\d).{6,}$/; // at least 6 chars, containing at least one digit somewhere
hasDigitAhead.test('abcdef'); // false — no digit
hasDigitAhead.test('abc123'); // true

Choice patterns

| means "or," and combines naturally with grouping:

/cat|dog|bird/.test('I have a dog'); // true
/gr(a|e)y/.test('grey'); // true — matches "gray" or "grey"

The mechanics of matching, and backtracking

A regex engine tries to match starting at each position in the string, left to right, and within a match it tries the longest possible option first for quantifiers like * and + (this is what "greedy" means) — backtracking to a shorter option only if the longer one ultimately fails to let the rest of the pattern match.

'<a><b>'.match(/<.*>/)[0];  // "<a><b>" — greedy: grabs as much as possible
'<a><b>'.match(/<.*?>/)[0]; // "<a>" — lazy (the ?): grabs as little as possible

Greediness is usually what you want (matching the whole quoted string, not just up to the first internal character), but it's a classic source of subtle bugs when parsing things like HTML tags or quoted strings that can appear multiple times on one line — reach for the lazy *?/+? variants when you specifically want "the shortest thing that matches," as in the tag example above.

replace, search, and dynamically building patterns

String.prototype.replace accepts a regex as its first argument, and — very powerfully — a function as its second, letting you compute each replacement based on what actually matched:

'2026-09-08'.replace(/(\d{4})-(\d{2})-(\d{2})/, '$2/$3/$1');
// "09/08/2026" — $1, $2, $3 refer to the captured groups

'price: 42'.replace(/\d+/, (match) => String(Number(match) * 2));
// "price: 84"

Add the g flag to replace (or match) every occurrence instead of just the first:

'a1 b2 c3'.replace(/\d/g, '#'); // "a# b# c#"

search returns the index of the first match (or -1), similar to String.indexOf but pattern-aware; when a regex has the g flag and you call .exec() repeatedly on the same regex object, it remembers where it left off via its lastIndex property — useful for walking through every match one at a time in a loop.

Worked example: parsing a small config file

Config files in the classic "INI" style look like this:

; comment
name = JavaScript Deep Dive
[server]
port = 8080
host = localhost

Lines are one of: blank, a comment (starting with ;), a [section] header, or a key = value pair. A small parser built from a few targeted regexes handles all four cases cleanly:

function parseIni(text) {
const result = { global: {} };
let section = result.global;

for (const rawLine of text.split('\n')) {
const line = rawLine.trim();
if (line === '' || line.startsWith(';')) continue; // blank or comment

const sectionMatch = line.match(/^\[(.+)\]$/);
if (sectionMatch) {
section = result[sectionMatch[1]] = {};
continue;
}

const pairMatch = line.match(/^(\w+)\s*=\s*(.*)$/);
if (pairMatch) {
section[pairMatch[1]] = pairMatch[2];
} else {
throw new SyntaxError(`Unrecognized line: ${rawLine}`);
}
}
return result;
}

This is a genuinely realistic use of regular expressions: each line is checked against a small, focused pattern rather than one giant unreadable regex trying to parse the whole file at once — a good rule of thumb any time a pattern starts feeling unmanageable.

Code units and characters

One subtlety worth knowing: JavaScript strings are sequences of 16-bit code units (UTF-16), and some characters — many emoji among them — need two code units to represent one visible character. . and \w in a regex normally operate per code unit unless you add the u (Unicode) flag, which makes the engine treat such pairs as a single character:

'🙂'.length;              // 2 — two UTF-16 code units, not what you'd expect
[...'🙂'].length; // 1 — spreading a string iterates by actual character
/^.$/.test('🙂'); // false — "." only matches one code unit
/^.$/u.test('🙂'); // true — the "u" flag fixes this

Key takeaways

  • Character classes and quantifiers describe shapes of text; groups let you both structure a pattern and extract pieces of what matched.
  • Quantifiers are greedy by default (*, +) — add ? for a lazy version when you want the shortest match instead of the longest.
  • replace with a function argument is a powerful way to compute context-aware replacements.
  • Prefer several small, targeted regexes over one giant pattern when parsing structured text line-by-line.
  • Add the u flag when working with text that might contain multi-code-unit characters like emoji.

Try it yourself

  1. Write a regex that matches a simple email address shape: some word characters, an @, more word characters, a ., and 2–4 letters.
  2. Use .replace() with a regex and a function to convert every snake_case_word in a string to camelCase.
  3. Extend parseIni above so it also throws a helpful error if a [section] header appears with no closing bracket.
Hints
  1. Something like /^\w+@\w+\.[a-zA-Z]{2,4}$/ — not RFC-compliant, but good enough for form validation.
  2. Match /_([a-z])/g and use the replacer function to uppercase the captured letter.
  3. Check for a line starting with [ that doesn't match the full ^\[(.+)\]$ pattern, and throw there.

Summary

Regular expressions are a small, dense language of their own, and they earn that density back the first time you use one to replace fifty lines of manual character-checking with a single line. They're most valuable for validation and light parsing — for anything structurally deep (a real programming language, deeply nested data), you want an actual parser, which is exactly what we build next.

 
Up nextCh. 10Modules~30 min

🌟 Join the JavaScript360 community

Connect with fellow JS developers, and get free interview prep, React challenges, and DSA guides.

Join on WhatsApp →