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}— exactlyn{n,m}— betweennandm
/\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. replacewith 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
uflag when working with text that might contain multi-code-unit characters like emoji.
Try it yourself
- Write a regex that matches a simple email address shape: some word characters, an
@, more word characters, a., and 2–4 letters. - Use
.replace()with a regex and a function to convert everysnake_case_wordin a string tocamelCase. - Extend
parseIniabove so it also throws a helpful error if a[section]header appears with no closing bracket.
Hints
- Something like
/^\w+@\w+\.[a-zA-Z]{2,4}$/— not RFC-compliant, but good enough for form validation. - Match
/_([a-z])/gand use the replacer function to uppercase the captured letter. - 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.