skip to content

In JavaScript's String.prototype.replace, what arguments does a replacer function receive, and when would you use one instead of a replacement string containing $& or $1?

level: middleimportance: should knowfreq 46%

answer

  1. match first, input last
  2. one argument per capture group
  3. offset sits between them
  4. the return value is used verbatim
  5. no dollar expansion in the function form

basics

~20 s

A replacer function is called once per match with the whole matched text, then each capture group, then the match offset and the full input string (plus a groups object when the pattern has named captures). Its return value is inserted verbatim, with no dollar-sign expansion.

solid answer

~50 s

The replacer receives, in order: the full matched substring, then one argument per capture group, then the zero-based `offset` of the match, then the whole input string — and, if the pattern declares named captures, a final object of them. It runs once per replacement, left to right, and whatever it returns is coerced to a string and inserted **literally**. Reach for it whenever the replacement has to be *computed* rather than assembled: uppercasing what matched, looking a token up in a map, formatting a number, or deciding conditionally to leave the match alone by returning the match itself. The other big reason is safety — a replacement *string* expands `$$`, `$&`, `` $` ``, `$'`, `$1` and `$<name>`, so any `$` in data can splice unintended text into the output. A function's return value gets none of that treatment, which makes it the correct way to insert untrusted text.

code

javascript · 15 lines
javascript
const vars = { name: 'Ada', role: 'engineer' };

const out = 'Hi {name}, the {role} — {missing}'.replace(
  /\{(\w+)\}/g,
  (match, key, offset, input) => {
    console.log(offset, input.length);
    return key in vars ? vars[key] : match; // leave unknown keys alone
  },
);

console.log(out); // 'Hi Ada, the engineer — {missing}'

// Return values are inserted verbatim — no $ expansion:
console.log('x'.replace(/x/, () => '$&')); // '$&'
console.log('x'.replace(/x/, '$&'));       // 'x'

go deeper

for a junior

Know that the second argument to replace can be a function, that it is called once per match, and that its first argument is the text that matched.

for a middle

State the full argument order — match, each capture, offset, input, then a named-groups object when present — and explain that the return value is inserted verbatim with no dollar-sign expansion.

for a senior

Choose the function form deliberately for computed, conditional or position-aware replacements, and treat it as the safe channel for inserting text you did not author.

for a principal

Set the convention that any replacement derived from data goes through a replacer function rather than a template string, so the $-expansion trap cannot appear in the codebase at all.

## The two kinds of replacement `String.prototype.replace` and `replaceAll` accept either a **string** or a **function** as the second argument. They are not interchangeable: the string form is a mini-template language, the function form is arbitrary computation. ## The string form is a template, not a literal In a replacement string these sequences are expanded: | Sequence | Inserts | |---|---| | `$$` | a literal `$` | | `$&` | the matched substring | | `` $` `` | the portion of the input before the match | | `$'` | the portion of the input after the match | | `$1` … `$99` | the numbered capture group | | `$<name>` | the named capture group | Anything else beginning with `$` is left alone. This is convenient for simple rewrites — `'2026-08-19'.replace(/(\d+)-(\d+)-(\d+)/, '$3/$2/$1')` gives `'19/08/2026'` — and dangerous when the replacement text is data: ```js const userName = "$&$&"; 'hello NAME'.replace('NAME', userName); // 'hello NAMENAME' ``` ## The function form and its argument list When the replacement is a function, the engine calls it once per match with a positional argument list: ```js 'a1 b2'.replace(/([a-z])(\d)/g, (match, letter, digit, offset, input) => { return `${letter.toUpperCase()}${Number(digit) * 2}`; }); // 'A2 B4' ``` The order is fixed: `match`, then `p1 … pn` (one per capture group in the pattern, `undefined` for a group that did not participate), then `offset`, then the whole `input` string. When the pattern declares named capture groups, one further argument follows — an object mapping those names to their captured text. Because the count of capture arguments varies with the pattern, a generic helper collects them with a rest parameter and pulls the fixed ones off the end. The function's return value is converted with `String()` and inserted **exactly as returned**. No `$` expansion happens. Returning `undefined` inserts the string `'undefined'` — a common bug in a replacer with a conditional branch that forgets to return the original match. ## When the function form is the right call **Computation.** Anything the template language cannot express: arithmetic, case conversion, date formatting, escaping. ```js const escapeHtml = (s) => s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;', })[c]); ``` **Lookup.** Template interpolation against a data object, where the replacement depends on the captured key. **Conditional replacement.** Return `match` unchanged to leave that occurrence alone — the only way to skip individual matches, since a pattern either matches or does not: ```js text.replace(/\b\d+\b/g, (m) => (Number(m) > 100 ? 'BIG' : m)); ``` **Position-aware output.** The `offset` argument lets you treat the first match differently, or record where substitutions happened. **Safety.** Inserting text you did not author. A function's return value is never re-scanned for `$` patterns, so no injection through the replacement channel is possible. If you must use the string form with untrusted text, double every dollar first: `value.replaceAll('$', '$$$$')` — four dollars in the source because that replacement string is itself template-expanded. **Side effects.** Because it runs once per match, a replacer can also count or collect matches while it rewrites, though `matchAll` is the cleaner tool when rewriting is not actually needed. ## Ordering and call count With a global regex, the replacer is invoked once per non-overlapping match, in left-to-right order, and each call sees the **original** input string as its last argument — not a partially rewritten one. Replacements never feed back into the matching, so a replacer that emits text matching the pattern will not cause a second pass. Without the `g` flag it is invoked at most once. If there is no match, it is never invoked at all, and `replace` returns the input string unchanged. ## The usual mistakes Returning nothing from one branch (yields `'undefined'`); assuming the last argument is always the input string when the pattern has named groups (it is the groups object then); assuming captures are passed as an array rather than as separate positional arguments; and passing a *call* rather than the function itself — `replace(re, fn())` invokes `fn` once, immediately, and hands `replace` its return value.

  • How do you write a replacer that works for a pattern with an unknown number of capture groups?
    Collect everything with a rest parameter and take the fixed arguments off the end: `(match, ...rest) => { const input = rest.pop(); const offset = rest.pop(); /* rest is now the captures */ }`. Watch out for named groups — when the pattern has them, an extra groups object sits after the input string, so pop that first.
  • What does a replacer function return to leave a particular match untouched?
    The match itself — the function's first argument. `text.replace(re, (m) => shouldSkip(m) ? m : rewrite(m))`. There is no other mechanism for skipping an individual occurrence, because the regex either matched or it did not. Returning `undefined` does not skip it; it inserts the literal text `'undefined'`.
  • Does the input string argument reflect earlier replacements when the regex is global?
    No. Every invocation receives the original, unmodified input, and the `offset` is an index into that original. Replacements are assembled into a new string as the engine goes, and emitted text is never re-scanned, so a replacer that outputs something matching the pattern will not trigger another substitution.

saying these in an interview costs you the question

  • Thinks the capture groups arrive as one array argument
  • Forgets to return the match in a conditional branch
  • Assumes the last argument is always the input string
  • Believes $& is expanded in a function's return value
  • Passes fn() instead of fn as the replacement

context