skip to content

Why does /^cat|dog$/.test('hotdog') return true in JavaScript, and how do you write the pattern you probably meant?

level: middleimportance: should knowfreq 42%

answer

  1. one operator binds loosest of all
  2. the split is wider than it looks
  3. anchors ended up on one branch only
  4. brackets around the whole expression
  5. non-capturing group restores the intent

basics

~20 s

Alternation has the lowest precedence in a regex, so the pattern splits into two whole branches: ^cat, or dog$. 'hotdog' ends with dog, satisfying the second branch. Group the branches — /^(?:cat|dog)$/ — to make the anchors apply to both.

solid answer

~40 s

The `|` operator binds more loosely than anything else in a pattern, so it splits the entire enclosing expression, not just the tokens next to it. `/^cat|dog$/` therefore means "starts with cat" OR "ends with dog", and `'hotdog'` satisfies the second branch. The fix is to scope the alternation explicitly: `/^(?:cat|dog)$/` puts both anchors outside a group that holds only the two words. This is the single most common regex reading error, and it shows up wherever a pattern mixes alternation with anchors or with surrounding literal text — `/^ab|cd$/`, `/prefix-a|b/`, and so on. Use a non-capturing group unless you actually want the chosen branch captured; either way, the parentheses are what make the intent unambiguous.

code

javascript · 3 lines
javascript
console.log(/^cat|dog$/.test('hotdog'));     // true  — parses as (^cat)|(dog$)
console.log(/^(?:cat|dog)$/.test('hotdog')); // false
console.log(/^(?:cat|dog)$/.test('dog'));    // true

go deeper

for a junior

Be able to say that | splits the whole pattern into alternatives and that grouping the branches is how you make anchors apply to all of them.

for a middle

State the precedence order — atoms, then quantifiers, then concatenation, then alternation — and correct a broken pattern live, including the prefix-branch ordering trap.

for a senior

Bring the review instinct: patterns mixing anchors with alternation are a standing defect source, and \b silently misbehaves on non-ASCII input, so you check both when a matcher misses records.

for a principal

Own the guardrails — shared patterns get tests with a negative case per branch, and you decide when input should be normalised or structured so that pattern matching stops carrying so much weight.

## Precedence, stated plainly Regex syntax has an operator precedence just like arithmetic does, and `|` sits at the bottom of it. From tightest to loosest: 1. Escaped characters, character classes, groups — the atoms. 2. Quantifiers (`*`, `+`, `?`, `{n,m}`) — bind to the single preceding atom. 3. Concatenation — a sequence of atoms in a row. 4. Alternation `|` — splits everything else. So `/^cat|dog$/` parses as `(^cat)|(dog$)`, not as `^(cat|dog)$`. The two branches are complete, independent alternatives; each is tried in order at each starting position, and the first that succeeds wins. ```js console.log(/^cat|dog$/.test('hotdog')); // true — branch 2: dog$ console.log(/^cat|dog$/.test('catalog')); // true — branch 1: ^cat console.log(/^(?:cat|dog)$/.test('hotdog'));// false console.log(/^(?:cat|dog)$/.test('dog')); // true ``` The same mistake with plain text instead of anchors is just as common: `/error: timeout|refused/` means "the literal `error: timeout`" or "the bare word `refused`", and will happily match a line that says `connection refused` with no `error:` prefix at all. ## Fixing it Wrap the alternatives in a group so the alternation cannot reach past it: `/^(?:cat|dog)$/`, `/error: (?:timeout|refused)/`. Use a **non-capturing** group unless you want to know which branch matched; if you do want that, a capturing or named group is the natural place to record it. Grouping is the fix in every case — there is no flag or setting that changes `|` precedence. A related trap is quantifier scope, which is the same class of error one level up: `/ab+/` repeats only the `b`, and `/(?:ab)+/` repeats the pair. Quantifiers bind to one atom; parentheses are how you make several tokens into one atom. ## Anchors and boundaries are assertions, not characters Because this question is usually really about anchors, know what they assert. `^` asserts the start of input, `$` the end of input — or, with the `m` flag, the start and end of each line. Neither consumes a character; both are zero-width, which is why `/^$/` matches the empty string. `\b` is the word-boundary assertion: it succeeds at a position where exactly one side is a "word character", where word character means `[A-Za-z0-9_]` — ASCII only. That last detail bites: ```js console.log(/\bcat\b/.test('concatenate')); // false — no boundary either side console.log(/\bcat\b/.test('the cat sat')); // true console.log(/\bcafé\b/.test('café')); // false ``` The third line surprises people. `é` is not in `\w`, so between `é` and the end of the string there is no word/non-word transition and therefore no boundary. For non-ASCII text, `\b` is simply the wrong tool; you need explicit lookarounds over the character classes you actually mean. `\B` is the negation — it asserts a position that is *not* a word boundary. ## Branch order matters Alternation is ordered and non-greedy across branches: the engine tries the leftmost branch first and, if the rest of the pattern can complete, keeps it. It does not prefer the longest alternative. ```js console.log('football'.match(/foot|football/)[0]); // 'foot' console.log('football'.match(/football|foot/)[0]); // 'football' ``` So when one alternative is a prefix of another, put the longer one first — otherwise the shorter always wins. This is a routine bug in keyword and unit patterns (`/m|min|month/` will never reach `month`). ## Reading patterns defensively The habit worth building: whenever you see a `|`, mentally draw brackets around the whole enclosing expression and ask whether that is really the split you want. If the answer is no — and it usually is when anchors, prefixes, or suffixes are present — add a group. Being able to state "alternation has the lowest precedence, so it splits the entire enclosing expression" and then correct a broken pattern on the spot is exactly what the interviewer is checking.

  • Given the pattern /foot|football/, what does 'football'.match(...)[0] return, and why?
    `'foot'`. Alternation is ordered, not longest-match: the engine tries the leftmost branch first and keeps it as soon as the rest of the pattern can complete — and here nothing follows. Put the longer alternative first, `/football|foot/`, when one branch is a prefix of another.
  • Why does /\bcafé\b/ fail to match the string 'café'?
    `\b` asserts a transition between a `\w` character and a non-`\w` one, and `\w` is ASCII-only: `[A-Za-z0-9_]`. `é` is not a word character, so at the end of the string there is no transition and the trailing `\b` fails. For non-ASCII text use explicit lookarounds instead.
  • Should you use a capturing or a non-capturing group to scope an alternation?
    Non-capturing by default — you are grouping for precedence, not to read a value, and a stray capture renumbers everything after it. Reach for a capturing or named group only when the calling code genuinely needs to know which branch matched.

saying these in an interview costs you the question

  • Thinks | binds only to the adjacent characters
  • Believes alternation picks the longest matching branch
  • Assumes an anchor at either end applies to all branches
  • Says \b works on accented or non-Latin letters
  • Adds a capturing group purely to fix precedence

context