Skip to content

How Text Is Replaced

m4bwav edited this page Sep 30, 2026 · 1 revision

Once the arguments pass the checks on Edge cases and errors, the result is always the part of the text before position, then newString, then the part of the text that starts sourceString.length code units after position. That one rule predicts every answer:

import replaceStringAtPosition from 'replace-string-at-position';

const text = 'abcdef';
console.log(text.slice(0, 2) + 'X' + text.slice(2 + 'cd'.length));
console.log(replaceStringAtPosition(text, 'cd', 'X', 2));

prints:

abXef
abXef

The repository's property test checks this against slice and against 1.0.4's own formula for 400 random texts at every position, and the golden test checks 26 answers recorded from the published 1.0.4. Every well-formed call gives the same answer in 1.0.4 and 2.0.0.

Only the length of sourceString counts

The text at position is never compared with sourceString. A sourceString that does not match still removes as many code units as it is long, and one that runs past the end removes the rest of the text:

import replaceStringAtPosition from 'replace-string-at-position';

replaceStringAtPosition('hello', 'xyz', 'abc', 0);
//=> 'abclo'
replaceStringAtPosition('abcdef', 'zz', 'X', 1);
//=> 'aXdef'
replaceStringAtPosition('abc', 'bcdef', 'X', 1);
//=> 'aX'

If the position can be stale (the text changed after you found it), check first; Recipes has a guard.

Inserting, deleting and appending

An empty sourceString inserts, an empty newString deletes, and a position equal to the text's length appends. Position 0 in an empty text is allowed, and -0 counts as 0:

import replaceStringAtPosition from 'replace-string-at-position';

replaceStringAtPosition('abc', '', 'X', 1);
//=> 'aXbc'
replaceStringAtPosition('abc', 'b', '', 1);
//=> 'ac'
replaceStringAtPosition('abc', 'x', 'Y', 3);
//=> 'abcY'
replaceStringAtPosition('', 'a', 'X', 0);
//=> 'X'
replaceStringAtPosition('abc', 'a', 'X', -0);
//=> 'Xbc'

A position past the end throws a RangeError in 2.0.0; 1.0.4 appended there too.

Positions count UTF-16 code units

Positions and lengths are JavaScript string indexes: UTF-16 code units, the same units as String.prototype.slice, indexOf and the index of a RegExp match. An emoji outside the Basic Multilingual Plane is two units long, and a position between its two halves splits it, leaving a lone surrogate:

import replaceStringAtPosition from 'replace-string-at-position';

'😀'.length;
//=> 2
replaceStringAtPosition('a😀b', '😀', 'X', 1);
//=> 'aXb'
replaceStringAtPosition('😀😀', '😀', 'x', 2);
//=> '😀x'
replaceStringAtPosition('😀b', 'b', 'X', 1);
//=> '\ud83dXb'

Node's console.log shows the lone surrogate as the escape \ud83d. Positions from indexOf or a regular expression never fall inside a pair, because those search in the same units. To count in code points instead, convert the position first (Recipes).

The package does not normalise text either. é can be one code unit (composed) or two (e and a combining accent), and a position counts whichever the text holds:

import replaceStringAtPosition from 'replace-string-at-position';

const composed = 'été'.normalize('NFC');
const decomposed = 'été'.normalize('NFD');
console.log(composed.length, decomposed.length);
console.log(replaceStringAtPosition(composed, 'é', 'E', 0));
console.log(replaceStringAtPosition(decomposed, 'e', 'E', 0).normalize('NFC'));

prints:

3 5
Eté
Été

String objects

Each text argument may be a string or a String object. A String object is read by its internal value; its own valueOf, toString or Symbol.toPrimitive is never called (1.0.4 called them):

import replaceStringAtPosition from 'replace-string-at-position';

const wrapped = new String('X');
wrapped.valueOf = () => 'Z';
wrapped.toString = () => 'Z';
console.log(replaceStringAtPosition(new String('abc'), 'b', wrapped, 1));

prints:

aXc

A String object from another realm (an iframe, vm.runInNewContext) is accepted too; the repository's unit tests check that one. A Proxy around a String object is refused (Edge cases and errors).

Extra arguments

Arguments after the fourth are ignored:

import replaceStringAtPosition from 'replace-string-at-position';

replaceStringAtPosition('abc', 'b', 'X', 1, 'ignored');
//=> 'aXc'

Cost

The function makes two slices and one concatenation, so it takes time in proportion to the text's length and allocates a new string each call. Many edits to one long text each copy it; Recipes applies them in one pass from the end, which keeps every position valid but still copies once per edit. No benchmark was run for this wiki.

Clone this wiki locally