Negative lookahead is the mirror image of the lookahead you just learned: instead of requiring that something does come next, it requires that something does not. Like positive lookahead, it’s zero-width — it checks and moves on, without consuming any characters.
The syntax
Negative lookahead is written (?!...). It succeeds only if the pattern inside does not match at the current position:
const text = "foobar foobaz foo foo bar";
const matches = text.match(/foo(?!bar)/g);
console.log(matches); // ["foo", "foo", "foo"]
Three matches, not four: the “foo” inside “foobar” is rejected because it’s immediately followed by “bar”, which (?!bar) explicitly forbids. Every other “foo” — including the standalone one and the one followed by a space — passes the check.
Matching a word not followed by X
This is the pattern’s classic use case: matching something except when a specific thing follows it. Think of it as an exclusion filter built directly into the regex, instead of matching everything and filtering the results afterward in code.
// Match "TODO" unless it's already marked done
const text = "TODO: fix bug\nTODO(done): cleanup\nTODO: add tests";
const openTodos = text.match(/TODO(?!\(done\))/g);
console.log(openTodos); // ["TODO", "TODO"]
Only two matches come back — the TODO(done) line is correctly skipped, because (?!\(done\)) fails whenever the literal text (done) follows immediately. Nothing about \(done\) is consumed or removed from the string; it’s purely a condition on what comes next.
Filtering out unwanted matches
Without negative lookahead, “match everything except this one case” usually means matching broadly and then throwing away results in a second pass — filtering an array, adding an if check, and so on. Negative lookahead lets you express the exclusion directly in the pattern, so the unwanted text is never in the match set to begin with:
// The hard way: match everything, filter afterward
const all = text.match(/TODO(?:\(done\))?/g) ?? [];
const openOnly = all.filter((t) => !t.includes("(done)"));
// The lookahead way: exclusion baked into the pattern itself
const openOnly2 = text.match(/TODO(?!\(done\))/g) ?? [];
Both approaches reach the same result, but the lookahead version means any other code that consumes this regex — a replace(), a split(), a validation check — automatically respects the exclusion too, with no separate filtering step to remember.
Positive vs. negative lookahead
Positive (?=...) |
Negative (?!...) |
|
|---|---|---|
| Succeeds when… | The pattern inside does match ahead | The pattern inside does not match ahead |
| Consumes characters | No | No |
| Typical use | Require a condition to be present | Exclude a specific case |
| Example | \d+(?=px) — digits followed by “px” |
foo(?!bar) — “foo” not followed by “bar” |
Tip
Negative lookahead only checks the very next characters at that position — it doesn’t automatically respect word boundaries. If you need “not followed by the word bar” rather than “not immediately followed by these literal characters,” pair it with \b, e.g. foo(?!\s*bar\b).
Exclude a specific extension
Write a pattern that matches a filename ending in a dot followed by letters (e.g. report.pdf, notes.txt), but excludes any file ending specifically in .tmp.
Show solution
const files = "report.pdf notes.txt cache.tmp draft.tmp".match(
/\w+\.(?!tmp\b)\w+/g
);
console.log(files); // ["report.pdf", "notes.txt"](?!tmp\b) checks, right after the dot, that the extension isn’t exactly tmp — so cache.tmp and draft.tmp are both excluded, while every other extension still matches normally.
What’s next
You’ve now covered groups, alternation, and both flavors of lookahead — the core tools for building precise, self-documenting patterns. From here, the course moves on to applying these building blocks to real-world parsing and validation problems.