GUIDE · JavaScript
Regex
At a glance
- FIRST VISIT
- From Session 9.1, read the METHODS row, then section 1 (test) and the isSlug line in section 10: that is the shape check an id needs. Stop there; the other sections are lookup for when a pattern misbehaves.
- FLAGS
- g global, i ignore case, m multiline (^$ per line), s dotAll, u unicode, y sticky
- CLASSES
- \d \w \s and negations \D \W \S; [a-z]; [^abc]; . (not newline)
- QUANT
- * + ? {n} {n,} {n,m}; lazy with trailing ? (.*? vs .*)
- ANCHORS
- ^ $ \b (word boundary)
- GROUPS
- (x) capture, (?:x) non-capture, (?<name>x) named, \1 backref, (?=x) lookahead, (?!x) neg lookahead, (?<=x) lookbehind, (?<!x)
- METHODS
- re.test(s), s.match(re) (all with /g, else groups), s.matchAll(re), s.replace(re, str|fn), s.split(re), re.exec(s) (stateful with /g!)
- TRAP
- escape . * + ? ( ) [ ] { } | ^ $ \ /; /g regex objects keep lastIndex; match() without /g returns [full, ...groups] with .index
- SAY
- greedy vs lazycapture groupbackreferencelookaroundanchoredReDoS / catastrophic backtracking
The idea#
Think of a regular expression as a stencil you lay over a sentence. The holes are the rules for which characters show through. \d fits a digit, \w a word character, \s whitespace. \D, \W, and \S are the opposites. [a-z] is a hole you cut yourself. [^abc] fits anything except those letters. A dot fits any character except a newline.
Quantifiers say how many times a hole repeats. * is zero or more, + is one or more, ? is zero or one, {n} is exactly n, {n,} is n or more, {n,m} is a range. A greedy stencil stretches as far as it can while the rest of the pattern still matches. A lazy one adds a ?, so .*? stops at the first fit. On <b>x</b><b>y</b>, greedy <b>.*</b> covers both tags. Lazy <b>.*?</b> covers only the first.
Anchors pin the stencil. ^ is the start, $ is the end, \b is the edge of a word. With the m flag, ^ and $ mean each line. A capture group (x) is a labeled hole. (?:x) groups without a label. (?<y>x) names the label, and you read it on .groups. \1 means the same text that fell through hole 1, a backreference. Lookahead (?=x) and (?!x), and lookbehind (?<=x) and (?<!x), peek without taking those characters. A price can require a $ and still leave the dollar sign out of the match.
Flags change the stencil. g finds every fit. i ignores case. m is multiline. s lets a dot cross a newline. u is Unicode, so one emoji is one character and \p{L} means a letter. y is sticky: the next search must start at lastIndex.
test answers yes or no. match with g returns every full match, or null. Without g you get the full text, the groups, and .index. matchAll needs g. replace takes a string pattern ($1, $<name>) or a function. split keeps separators when you capture them. exec with g remembers its place.
Why it matters#
The trap is lastIndex. A /g regex remembers how far it got. /a/g tested on 'a' is true the first time and false the second, because the stencil starts past the end. Set lastIndex back to 0, or stop reusing one global regex with test. match and matchAll are the other way through the sentence.
Characters that belong to the stencil language are the other trap. . * + ? ( ) [ ] { } | ^ $ \ / are holes and pins. If you build new RegExp from raw user text, a.b also fits axb. Escape those characters first. Nested quantifiers on overlapping classes, such as (a+)+ at the end, can try a ridiculous number of stretches. That is catastrophic backtracking, the ReDoS smell. Anchor the pattern and keep the classes apart. A complete email regex does not exist.
Say greedy versus lazy, capture group, backreference, lookaround, anchored, and ReDoS.
A small example#
const greedy = 'axxxaaxxxa'.match(/a.*a/)[0];const lazy = 'axxxaaxxxa'.match(/a.*?a/)[0];const titled = 'hello world'.replace(/\b\w/g, (ch) => ch.toUpperCase());const amounts = 'price: $42, cost: $7'.match(/(?<=\$)\d+/g);greedy is the whole 'axxxaaxxxa'. lazy stops at 'axxxa'. titled is 'Hello World'. amounts is ['42', '7']: digits after a $, with the dollar sign left in place.
What the code below is doing#
test checks a whole ^\d{3}-\d{4}$ string. match with g pulls every digit run, and returns null when nothing fits, so guard with ?? []. Without g, 'key: value' comes back as the full match, two groups, and .index. A named date exposes y, mo, and d on .groups. That object has a null prototype, so the sheet spreads it before comparing. matchAll on 'a=1;b=2' needs g.
replace turns dots into dashes, swaps two words with $2, $1, rewrites a named date, wraps digit runs by length, capitalizes at word edges, converts snake case and camel case, and collapses whitespace after trim.
Greedy versus lazy is the two <b> matches. Lookaround takes digits after $, letters followed by a digit, a password built from lookaheads, and thousands commas.
Backreferences match a doubled letter, collapse a run to one character, and require the closing quote to be the same mark as the opening quote.
split on digits drops them and leaves a trailing empty string. A capturing split keeps the separators. Commas and semicolons, plus optional space, split a short list.
User text is escaped by putting a backslash in front of every metacharacter. a.b then matches a.b and misses axb. A string-built '\\d+' is the digit class.
The same /a/g tests true, then false, then true again after lastIndex = 0. Do not reuse a global regex across test calls unless you reset that index.
i matches HELLO to hello. m lets ^\w$ hit both lines of 'a\nb'. A dot misses a newline unless s is set. u treats an emoji as one character, and \p{L} matches the letters in héllo.
The validators are a rough email, a hex color, an IPv4 address whose parts stay in range, an optional-minus integer, and a lowercase slug joined by single hyphens. '256.1.1.1', '4.2', and 'My Post' fail.
Tokenizing walks '3 + 42 * (x-1)' with matchAll. Sticky y is the other way to resume at lastIndex. Nested quantifiers on overlapping classes are the ReDoS smell.
Everything below is the demonstration. Same idea, written as code you can run.
'use strict';const assert = require('node:assert/strict');test / match / matchAll#
const m = 'key: value'.match(/^(\w+): (\w+)$/); // no /g -> first match + groupsconst named = '2024-01-31'.match(/(?<y>\d{4})-(?<mo>\d{2})-(?<d>\d{2})/).groups;const all = [...'a=1;b=2'.matchAll(/(\w)=(\d)/g)].map(x => [x[1], Number(x[2])]); // matchAll needs /gVerified by 7 asserts
assert.equal(/^\d{3}-\d{4}$/.test('555-1234'), true);assert.deepEqual('x=1, y=22'.match(/\d+/g), ['1', '22']); // /g -> all matches, no groupsassert.equal('abc'.match(/\d/g), null); // no match -> null (guard with ?? [])assert.equal(m[0], 'key: value');assert.deepEqual([m[1], m[2], m.index], ['key', 'value', 0]);assert.deepEqual({ ...named }, { y: '2024', mo: '01', d: '31' }); // .groups has a null prototype — spread it before deepEqualassert.deepEqual(all, [['a', 1], ['b', 2]]);replace with string patterns and functions#
Verified by 8 asserts
assert.equal('a.b.c'.replace(/\./g, '-'), 'a-b-c');assert.equal('John Smith'.replace(/(\w+) (\w+)/, '$2, $1'), 'Smith, John');assert.equal('2024-01-31'.replace(/(?<y>\d+)-(?<m>\d+)-(?<d>\d+)/, '$<d>/$<m>/$<y>'), '31/01/2024');assert.equal('a1b22'.replace(/\d+/g, d => `[${d.length}]`), 'a[1]b[2]');assert.equal('hello world'.replace(/\b\w/g, c => c.toUpperCase()), 'Hello World');assert.equal('snake_case_name'.replace(/_(\w)/g, (_, c) => c.toUpperCase()), 'snakeCaseName');assert.equal('camelCaseName'.replace(/[A-Z]/g, c => `_${c.toLowerCase()}`), 'camel_case_name');assert.equal(' a b '.trim().replace(/\s+/g, ' '), 'a b');Greedy vs lazy#
Verified by 2 asserts
assert.equal('<b>x</b><b>y</b>'.match(/<b>.*<\/b>/)[0], '<b>x</b><b>y</b>'); // greedy eats everythingassert.equal('<b>x</b><b>y</b>'.match(/<b>.*?<\/b>/)[0], '<b>x</b>'); // lazy stops earlyLookaround#
Verified by 4 asserts
assert.deepEqual('price: $42, cost: $7'.match(/(?<=\$)\d+/g), ['42', '7']); // lookbehind: digits after $assert.deepEqual('a1 b2 c'.match(/[a-z](?=\d)/g), ['a', 'b']); // letters followed by digitassert.equal(/^(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{8,}$/.test('Passw0rdX'), true); // password policy via lookaheadsassert.equal('1234567'.replace(/\B(?=(\d{3})+(?!\d))/g, ','), '1,234,567'); // thousands separatorBackreferences#
Verified by 3 asserts
assert.equal(/(\w)\1/.test('hello'), true); // doubled letterassert.equal('aaabcc'.replace(/(.)\1+/g, '$1'), 'abc'); // collapse runsassert.equal(/^(['"]).*\1$/.test('"quoted"'), true); // matching quotesSplit#
Verified by 3 asserts
assert.deepEqual('a1b2c3'.split(/\d/), ['a', 'b', 'c', '']);assert.deepEqual('a1b2c3'.split(/(\d)/), ['a', '1', 'b', '2', 'c', '3', '']); // capture group keeps separatorsassert.deepEqual('one, two;three'.split(/[,;]\s*/), ['one', 'two', 'three']);Escaping user input into a regex#
const escapeRe = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');const re = new RegExp('\\d+', 'g'); // string form: double the backslashesVerified by 3 asserts
assert.equal(new RegExp(escapeRe('a.b')).test('axb'), false);assert.equal(new RegExp(escapeRe('a.b')).test('a.b'), true);assert.equal(new RegExp(`^${escapeRe('(x)')}$`).test('(x)'), true);The /g lastIndex trap#
const g = /a/g;g.lastIndex = 0;// Rule: don't reuse a /g regex object across test() calls; or reset lastIndex; or use match()/matchAll().Verified by 3 asserts
assert.equal(g.test('a'), true);assert.equal(g.test('a'), false); // lastIndex moved past the endassert.equal(g.test('a'), true);Flags#
Verified by 6 asserts
assert.equal(/HELLO/i.test('hello'), true);assert.deepEqual('a\nb'.match(/^\w$/gm), ['a', 'b']); // m: ^$ per lineassert.equal(/a.b/.test('a\nb'), false);assert.equal(/a.b/s.test('a\nb'), true); // s: dot matches newlineassert.equal(/^.$/u.test('😀'), true); // u: code pointsassert.equal(/\p{L}+/u.test('héllo'), true); // unicode property escapesCommon validators (good enough for interviews; say "RFC-complete email regex doesn't exist")#
const isEmail = s => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s);const isHex = s => /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.test(s);const isIPv4 = s => /^((25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(25[0-5]|2[0-4]\d|1?\d?\d)$/.test(s);const isInt = s => /^-?\d+$/.test(s);const isSlug = s => /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(s);Verified by 2 asserts
assert.equal(isEmail('a@b.co') && !isEmail('a@b') && isHex('#fff') && isIPv4('192.168.1.1') && !isIPv4('256.1.1.1'), true);assert.equal(isInt('-42') && !isInt('4.2') && isSlug('my-post-1') && !isSlug('My Post'), true);Tokenizing (regex + sticky or matchAll)#
const tokens = [...'3 + 42 * (x-1)'.matchAll(/\d+|[a-z]+|[-+*/()]/g)].map(t => t[0]);Verified by 1 assert
assert.deepEqual(tokens, ['3', '+', '42', '*', '(', 'x', '-', '1', ')']);ReDoS smell: nested quantifiers on overlapping classes, e.g. /(a+)+$/#
avoid; anchor and keep classes disjoint.
Verified by 1 assert
assert.equal(typeof re.exec, 'function');See also