JAVASCRIPT / REGULAR EXPRESSIONS
Anchors, boundaries, and lookaround assertions
Constrain where a match may start and end with ^, $, \b, \B and lookaround, without pulling the surrounding context into the match itself.
What you will learn
- Anchor with ^ and $ for the whole string, or per line by adding the m flag
- Match whole words with \b, knowing it is defined purely by [A-Za-z0-9_]
- Stack (?=...) and (?!...) at one position to test several rules at once
- Use (?<=...) to require preceding context that stays out of the match
Understanding Anchors, boundaries, and lookaround assertions
A regex engine has a cursor that sits between characters rather than on them, and every construct in this lesson inspects what lies on either side of that cursor instead of consuming anything. That is what zero-width means: /\bcat\b/ still returns a three-character match, because the two boundaries add requirements without adding text. Once you treat ^, $, \b, \B and lookaround as questions about a position, the surprising behaviours follow naturally: several assertions can hold at the same index, and replace on an assertion inserts text instead of overwriting it.
^ matches only at index 0 and $ only at the very end of the input; JavaScript makes no exception for a trailing newline, so /^\w+$/.test("abc\n") is false. Adding the m flag redefines both to line edges, so they also match around \n, \r and the Unicode line separators. \b is a different animal: it is defined entirely in terms of \w, which is exactly [A-Za-z0-9_], and it holds wherever a \w character sits opposite a non-\w character or a string edge. \B is its complement and matches every remaining position, including the inside of a word and the inside of a run of spaces.
Lookahead asks about the text after the cursor, with (?=...) demanding a match and (?!...) demanding failure; lookbehind, (?<=...) and (?<!...), asks the same about the text before it and is matched right to left. Because neither moves the cursor, they express "match X only in context Y" without dragging Y into the result, which is why three lookaheads can all run at index 0 in a password check. Capture groups inside a successful lookaround keep their text and stay numbered, but a group inside a negative lookaround is always undefined, since that assertion only succeeds when its body failed to match.
const line = "cat catalog concat";
// \b tests a position, so the match is three characters long, not five
console.log(line.match(/\bcat\b/g));
console.log(line.match(/\bcat/g));
console.log(line.replace(/\bcat\b/g, "dog"));
// the lookahead requires a "$" but leaves it out of the match
console.log("42kg 42$".match(/\d+(?=\$)/g));
console.log("42kg".match(/\d+(?=\$)/));
// ^ and $ mean start and end of the whole string until m is set
const text = "one\ntwo";
console.log(text.match(/^\w+$/));
console.log(text.match(/^\w+$/gm));Anchors, boundaries and lookaround are assertions about a position in the string, so they decide where a match is allowed to happen without becoming part of what is matched.
Worked examples
Stacking lookaheads for a password rule
Three independent conditions checked at the same position, because no assertion moves the cursor.
const strong = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/;
for (const pw of ["hunter2", "Hunter22x", "hunterhunter"]) {
console.log(`${pw}: ${strong.test(pw)}`);
}Example explained
Line 1^ pins the cursor to index 0, and all three lookaheads run there because none of them advances it.
Line 2Each .* scan restarts from the start, so the lowercase, uppercase and digit may appear in any order.
Line 3.{8,}$ is the only part that consumes characters; drop it and the regex would match the empty string at index 0.
Line 4"hunterhunter" is long enough but fails the (?=.*[A-Z]) assertion, so test returns false.
Zero-width matches as insertion points
Grouping digits with commas by matching positions instead of characters.
const group = (digits) => digits.replace(/\B(?=(\d{3})+$)/g, ",");
console.log(group("123456"));
console.log(group("1234567"));
console.log(group("999"));Example explained
Line 1The pattern matches zero characters, so replace has nothing to overwrite and the comma is simply inserted at each match.
Line 2(?=(\d{3})+$) accepts a position only when the digits after it divide evenly into groups of three.
Line 3\B rules out index 0, which is the only reason "123456" does not come back as ",123,456".
Line 4"999" is untouched because no interior position has a whole number of triples ahead of it.
Lookbehind keeps context out of the result
Extracting bare module specifiers while ignoring relative paths, then the same regex without lookbehind.
const src = 'import a from "./a.js"; import b from "lodash"; import c from "react-dom";';
console.log(src.match(/(?<=from ")(?!\.)[^"]+/g));
console.log(src.match(/from "(?!\.)[^"]+/g));Example explained
Line 1(?<=from ") demands those six characters immediately before the cursor but keeps them out of the match.
Line 2(?!\.) rejects the position when a dot comes next, which discards "./a.js" and any other relative path.
Line 3[^"]+ is the only consuming part, so match returns the specifier alone with no capture group or slicing.
Line 4The second call turns the lookbehind into ordinary text, and from " ends up inside every match.
Important notes
Lookbehind arrived in ES2018 and Safari only shipped it in 16.4; on an older engine the regex literal is a parse-time SyntaxError that kills the whole script file, not just that line.
A negative lookaround succeeds only when its body fails to match, so capture groups written inside (?!...) or (?<!...) are always undefined; keep the groups outside them.
Common mistakes
Assuming ^ and $ work line by line: without the m flag /^\w+$/ never matches "one\ntwo", and it also rejects "abc\n", because JavaScript's $ allows no trailing newline.
Reading \b as "space or punctuation": /\bcafé\b/.test("café") is false because é is not a \w character, so accented words get silently rejected by word-bounded validation.
Driving a global zero-width pattern with re.exec in a while loop: lastIndex never advances past an empty match, so the loop spins forever and freezes the page.
Try it yourself
Change, predict, then run
In the console, take const css = "margin: 10px 0 -2em 4px" and write one global regex that returns exactly ["10", "4"], the pixel numbers without their unit. Then change 4px to -4px and add a negative lookbehind so the negative value is skipped, leaving ["10"].
Open the JavaScript workspaceCheck your understanding
What does "aaa".replace(/(?=a)/g, "-") return?
- -aaa
- -a-a-a
- -a-a-a-
- ---
Show answer
The lookahead matches zero characters, so the replacement is inserted rather than substituted: there is an empty match before each 'a' at indexes 0, 1 and 2, giving -a-a-a. "-a-a-a-" assumes a fourth match at index 3, but there is no 'a' ahead of the end of the string, so the assertion fails there. "---" would be right only if the pattern consumed the letters, which an assertion never does.