String methods

Understand immutable strings, UTF-16 indexing, search, replacement, and locale rules so JavaScript handles real text boundaries correctly.

level intermediate time 10 min at Standard depth
version Node 24
what

A JavaScript string is an immutable sequence of UTF-16 code units; string methods search, extract, transform, compare, or split that sequence.

trap

length, slice(), and most position arguments count UTF-16 code units, not necessarily user-perceived characters; replace() also replaces only the first string match by default.

fix

Decide whether the task needs code units, Unicode code points, or grapheme clusters before choosing a method; use Intl.Segmenter for user-visible text and Intl.Collator for locale-aware comparison.

What it is and why it exists

JavaScript strings represent text values. A string primitive is immutable: a method may inspect it or return a transformed string, but it can’t rewrite a position in the original. Assignment makes a variable refer to another string; it doesn’t mutate the old value.

String methods organize common text operations on String.prototype. You use includes() for a fixed fragment, slice() for a half-open range, replace() or replaceAll() for substitutions, and trim() for boundary whitespace. Case conversion, normalization, and locale-aware comparison methods handle other international text requirements.

These methods solve problems at different layers. A fixed delimiter suits indexOf() and slice(), a pattern language belongs to regular expressions, and a grammar requires a dedicated parser. split(',') isn’t a CSV parser, and a regular-expression replacement isn’t an HTML sanitizer.

The underlying counting unit directly affects results. JavaScript stores and indexes strings as UTF-16 code units . One Unicode code point may occupy one or two code units, and one symbol perceived by a user may contain several code points.

That makes “take the first ten characters” an incomplete requirement. A protocol field defined in code units can use length and slice() directly. A user interface that truncates visible symbols needs grapheme cluster segmentation. Name the counting unit first, and method selection becomes deterministic.

How it works

When you call text.toUpperCase(), JavaScript reads the value of text and runs the corresponding method. Transforming methods return new strings; query methods may return a Boolean, index, array, or iterator. The old variable doesn’t receive a result automatically, so you must assign or pass the result onward.

You can classify common methods by their result and matching model:

TaskPreferred methodKey contract
Test for fixed textincludes()Case-sensitive; returns a Boolean
Find a fixed-text positionindexOf(), lastIndexOf()Returns -1 when absent
Test a prefix or suffixstartsWith(), endsWith()Accepts a position or length argument
Extract a rangeslice()Excludes the end; accepts negative indices
Read one code unit by positionat()Accepts negative indices; returns undefined out of range
Divide on a separatorsplit()Capturing groups may enter the result array
Replace matchesreplace(), replaceAll()With a string search value, replaces the first or all matches
Remove boundary whitespacetrim(), trimStart(), trimEnd()Leaves internal whitespace unchanged
Pad to a target lengthpadStart(), padEnd()Measures the target in code units
Repeat a stringrepeat()May throw for an invalid count or oversized result
Normalize Unicodenormalize()Defaults to NFC; doesn’t perform case conversion
Compare for a localelocaleCompare(), Intl.CollatorDepend only on negative, positive, or zero

Immutable results and method chains

A method chain uses one return value as the next receiver. For example, input.trim().toLowerCase() first creates a string without boundary whitespace, then converts the case of that result. Chaining doesn’t change the semantics of either step or automatically validate an intermediate result.

Not every string method returns a string. includes() returns a Boolean, indexOf() returns a number, match() may return an array or null, and matchAll() returns an iterator. Generated code that keeps calling string methods without checking return types often fails far from the real mistake.

A primitive string can call prototype methods through automatic boxing, but it doesn’t permanently become a String object. Application code should normally use primitive strings. new String("text") creates an object whose truthiness and strict-equality behavior are easier to misuse.

Indices and half-open ranges

slice(start, end) returns code units from start up to, but not including, end. Omitting end extracts through the end, while negative arguments are translated from the string length. A half-open range means slice(0, n) usually has length n and adjacent ranges can be written as slice(0, cut) and slice(cut).

substring() also excludes its end, but treats negative numbers as 0 and swaps the arguments when the start exceeds the end. Unless you’re maintaining code that relies on that swapping behavior, new code usually benefits from the more direct semantics of slice(). The historical substr() method uses a “start plus length” model and shouldn’t be generated in new code.

Position arguments are still code-unit indices. An arbitrary slice() boundary in text containing surrogate pairs, combining marks, or zero-width joiners can create a lone surrogate or split one visible symbol. Protocol requirements and interface requirements need separate handling.

Fixed text, regular expressions, and replacement values

Prefer includes(), indexOf(), startsWith(), or endsWith() for fixed-text searches. Choose search(), match(), matchAll(), and regular-expression replacement when you need character classes, repetition, or capture groups. This keeps matching semantics and failure modes visible.

With a string search value, replace() handles the first occurrence and replaceAll() handles every non-overlapping occurrence. With a regular expression, the g flag determines global matching. Passing a non-global regular expression to replaceAll() throws TypeError.

Sequences such as $&, $1, $`` and $’` have special meanings inside a replacement string. If replacement content comes from data and must be inserted literally, pass a replacer function that returns it. The function’s return value isn’t interpreted again for those substitution markers.

Examples

These four examples progress from immutable cleanup to fixed-delimiter parsing, literal replacement, and grapheme-safe truncation. Every output shown comes from running the corresponding file with Node 24.14.0.

1. Clean a label while preserving its source

trim() removes only boundary whitespace, then a regular-expression replacement collapses internal whitespace to one space. The function returns a new value while the input string remains unchanged.

clean-label.js
function cleanLabel(value) {
  return value.trim().replace(/\s+/gu, " ");
}

const original = "  Priority\t order  ";
const cleaned = cleanLabel(original);

console.log(JSON.stringify(original));
console.log(JSON.stringify(cleaned));
console.log(original === "  Priority\t order  ");
console.log(cleaned.toUpperCase());
"  Priority\t order  "
"Priority order"
true
PRIORITY ORDER

JSON.stringify() makes the tab and boundary spaces visible in the output. The third line proves that cleanLabel() didn’t rewrite original; the final line creates another uppercase string from the cleaned result.

This cleanup rule suits a field whose contract says all internal whitespace is equivalent to one space. Code, poetry, preformatted text, and some natural-language content don’t satisfy that contract, so don’t apply it unconditionally.

2. Extract a field at its first delimiter

A header field separates its name and value at the first colon. Locating it with indexOf() and then applying two half-open slice() operations preserves later colons inside the value.

parse-header.js
function parseHeader(line) {
  const separator = line.indexOf(":");
  if (separator === -1) {
    throw new SyntaxError("missing colon");
  }

  const name = line.slice(0, separator).trim().toLowerCase();
  const value = line.slice(separator + 1).trim();
  return { name, value };
}

for (const line of [
  "Content-Type: application/json",
  "Location: https://example.test:8443/orders/7",
  "invalid header"
]) {
  try {
    console.log(JSON.stringify(parseHeader(line)));
  } catch (error) {
    console.log(`${error.name}: ${error.message}`);
  }
}
{"name":"content-type","value":"application/json"}
{"name":"location","value":"https://example.test:8443/orders/7"}
SyntaxError: missing colon

Calling line.split(':') and destructuring its first two entries would lose the URL’s port section. This code demonstrates only a controlled format. Real HTTP parsing still belongs to the runtime or a protocol library because the complete field grammar has more rules.

The -1 returned by indexOf() is a sentinel that must be handled. Passing it directly to slice() activates negative-index behavior and may turn malformed input into plausible-looking data.

3. Insert replacement data literally

The template markers are fixed strings, so replaceAll() is sufficient. A function returns each replacement value, preserving content such as $& literally.

fill-template.js
function fillTemplate(template, values) {
  let result = template;

  for (const [name, value] of Object.entries(values)) {
    const marker = `{{${name}}}`;
    result = result.replaceAll(marker, () => String(value));
  }

  const unresolved = [...result.matchAll(/\{\{(?<name>[a-z]+)\}\}/gu)]
    .map((match) => match.groups.name);
  if (unresolved.length > 0) {
    throw new Error(`unresolved: ${unresolved.join(", ")}`);
  }
  return result;
}

console.log(fillTemplate("Total: {{amount}}", { amount: "$&5" }));

try {
  console.log(fillTemplate("Hello {{name}} from {{team}}", { name: "Mira" }));
} catch (error) {
  console.log(error.message);
}
Total: $&5
unresolved: team

If this used replaceAll(marker, String(value)), $& would expand to the matched marker and the first line would incorrectly retain {{amount}}. A replacer function disables that layer of replacement-string syntax, but it doesn’t automatically give a homemade template system HTML escaping, access control, or safe expression evaluation.

The final matchAll() reports unresolved markers after all replacements. It uses a global regular expression and reads field names from a named capture group. If the template grammar grows, replace this with a defined template parser.

4. Truncate interface text by grapheme cluster

Intl.Segmenter applies Unicode text-segmentation rules to identify symbols users generally perceive. This example compares code-unit, code-point, and grapheme-cluster counts.

graphemes.js
const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });

function graphemes(text) {
  return Array.from(segmenter.segment(text), ({ segment }) => segment);
}

function truncateGraphemes(text, limit) {
  const parts = graphemes(text);
  return parts.length <= limit ? text : `${parts.slice(0, limit).join("")}…`;
}

const label = "👨‍👩‍👧‍👦 cafe\u0301";
console.log(label.length);
console.log([...label].length);
console.log(graphemes(label).length);
console.log(JSON.stringify(graphemes(label)));
console.log(truncateGraphemes(label, 3));
17
13
6
["👨‍👩‍👧‍👦"," ","c","a","f","é"]
👨‍👩‍👧‍👦 c…

The family emoji consists of several code points joined by zero-width joiners, while the final combines a letter and a combining mark. Spread syntax iterates by code point, so it still splits both kinds of grapheme cluster. The segmenter treats each as one interface symbol.

The "en" argument is an explicit locale . Grapheme boundaries are mostly language-independent, but an explicit locale makes the behavior and call intent clear. Word and sentence segmentation depend on language more strongly.

Pitfalls

Deep More than one text boundary

More than one text boundary

Code units, code points, and grapheme clusters

An ECMAScript string’s length is its number of UTF-16 code units. Bracket access, at(), charAt(), charCodeAt(), and slice positions also use code-unit indices. A supplementary-plane code point is represented by a surrogate pair, so "😀".length is 2.

The string iterator, spread syntax, and Array.from(string) recognize well-formed surrogate pairs and yield strings by code point. They avoid splitting one supplementary-plane code point in half, but a combining mark, emoji modifier, regional-indicator flag, or zero-width-joiner sequence may still span several results.

A grapheme cluster is closer to what users perceive as “one character.” Intl.Segmenter with granularity: "grapheme" reports boundaries using Unicode segmentation rules, but it neither changes the source string nor claims every cluster has equal display width. Terminal columns and font layout are separate problems.

OperationCounting unitSuitable requirement
text.lengthUTF-16 code unitsA JavaScript API’s native length contract
text.slice(a, b)UTF-16 code unitsA protocol range defined in code-unit offsets
[...text]Unicode code pointsIteration that doesn’t split well-formed surrogate pairs
Intl.Segmenter in grapheme modeGrapheme clustersSelecting or truncating user-visible symbols
Canvas or layout APIsRendered measurementsPixel width, wrapping, and layout

codePointAt(index) reads a code point from a code-unit position. If index points to the leading half of a surrogate pair, it returns the full code point; if it points to the trailing half, it returns only that trailing surrogate’s numeric value. It solves representation-aware reading, not code-point indexing.

String.fromCodePoint() constructs a string from code-point values, while String.fromCharCode() constructs it from 16-bit code units. Use the former when handling complete Unicode code points. The latter directly matches input only when a protocol supplies raw UTF-16 units.

Ill-formed Unicode

A JavaScript string can contain a lone surrogate code unit. It remains a valid ECMAScript string but isn’t a well-formed sequence of Unicode scalar values. Splitting a surrogate pair, decoding external binary data incorrectly, or explicitly constructing code units can produce such a value.

In Node 24, isWellFormed() detects lone surrogates and toWellFormed() replaces each one with U+FFFD. That conversion loses the original code unit, so perform it deliberately at a contract boundary rather than hiding it in generic cleanup.

Encoding APIs differ in how they handle ill-formed strings. Define the accepted Unicode form before transmitting text between systems and test lone surrogates. Don’t assume every receiver preserves or rejects the same input.

Search and replacement protocols

The indexOf() sentinel

indexOf(search, fromIndex) returns the code-unit index of the first match or -1 when absent. An empty search string always matches at the clamped start position, so text.includes("") is true. That call can’t validate that an input is nonempty.

Use includes() when you only need an existence test. Preserve the indexOf() result when you need a slice boundary or continued search. A loop finding every occurrence must also decide whether matches may overlap and how an empty search advances, or it may never terminate.

startsWith(), endsWith(), and includes() target fixed strings and don’t accept regular expressions. This restriction exposes a call that confuses patterns and literals. Choose a regular-expression API explicitly when you need a pattern.

Symbol-method dispatch

match(), matchAll(), search(), replace(), replaceAll(), and split() don’t merely convert every argument to a plain string. If an object implements the corresponding Symbol.match, Symbol.matchAll, Symbol.search, Symbol.replace, or Symbol.split, the operation dispatches to that protocol method. RegExp participates in string operations through these symbols.

That means a “pattern” supplied as an untrusted object may execute user code. Most application boundaries should accept a definite string or a controlled RegExp; don’t pass arbitrary objects to these methods under the assumption that the operation is a pure query.

The shape returned by match() depends on the regular expression’s g flag. A non-global match preserves captures and an index, while a global match primarily returns complete matched strings. Use matchAll() with a global expression when you need to iterate through every match and its capture groups.

Replacer-function arguments

A replacer function receives the complete match, each capture, the match offset, and the original string. A regular expression with named captures also adds a groups object. An optional capture that didn’t match supplies undefined. A generic wrapper can’t assume the penultimate argument is always the offset because groups changes the tail shape.

Replacement determines match positions from the original input; it doesn’t repeatedly rescan newly inserted text. Replacing one item with a value that contains the search text therefore doesn’t loop forever by itself. A sequence of replaceAll() calls still has order effects because a later step can process content produced by an earlier one.

A string search value is treated literally and needs no escaping for regular-expression metacharacters. When a dynamic requirement really means “replace this fixed text,” passing a string is usually safer than constructing a RegExp, and it avoids backslash and flag mistakes.

Normalization, case, and ordering

Unicode normalization

Visually identical text may use different code-point sequences. For example, precomposed "é" and "e\u0301" aren’t strictly equal. normalize("NFC") converts many canonically equivalent sequences to one composed form. NFD, NFKC, and NFKD have different decomposition and compatibility semantics.

Normalization isn’t cleanup, translation, or a security filter. Compatibility normalization may collapse typographic distinctions, while case conversion is a separate operation. An application must define the order and target form and apply them consistently to writes, queries, and uniqueness checks.

Don’t normalize received text implicitly before verifying a signature or hash because any code-unit change alters its byte representation. If a protocol requires a normalization form, apply it at the point defined by that protocol and make both parties use the same encoding and version rules.

Locale-sensitive case conversion

toLowerCase() and toUpperCase() use default Unicode case mappings and don’t accept a locale. toLocaleLowerCase() and toLocaleUpperCase() accept locale arguments and can handle locale-specific mappings such as Turkish I.

A case mapping can change length and isn’t guaranteed to round-trip to the source. The uppercase form of German lowercase ß expands to multiple code points. You therefore can’t align a case-converted string with its source by reusing source indices.

Protocol keywords, programming-language identifiers, and security tokens usually define their own case rules. Applying the user’s locale to these values makes results vary with the environment or account language. Follow the protocol first, and reserve locale-sensitive methods for display text.

Comparison and sorting

localeCompare() suits occasional locale-aware comparisons. For a large sort, create one Intl.Collator(locale, options) and reuse its compare. Options such as sensitivity, numeric, caseFirst, and usage change which differences participate in comparison and must come from product requirements.

Collation equality isn’t strict string equality. A collator configured to ignore accents or case may return 0 for strings with different code units. Search hits, deduplication, unique keys, and authorization checks shouldn’t share one loose comparator without a contract.

Ordering also depends on the runtime’s internationalization data. If several services or versions must produce a stable persistent order, save an explicit sort key or define a server-side ordering contract instead of assuming every environment has the same default locale.

Testing method boundaries

Test string methods by contract partitions rather than a few ordinary English words. At minimum, cover empty boundaries, absent states, starts and ends, repeated matches, and return types. For external input, also decide whether non-string values are rejected or explicitly converted.

Index-related tests should separately include a BMP character, supplementary-plane code point, combining sequence, and zero-width-joiner sequence. Those four inputs reveal whether code really operates on code units, code points, or grapheme clusters. They also catch tests titled “characters” whose assertions cover ASCII only.

Replacement tests should cover no match, one match, repeated matches, an unmatched capture, and replacement data containing $& or $1. A dynamic regular expression also needs metacharacter and backslash cases to verify whether the requirement is a literal or a pattern.

Locale-aware tests must fix the locale and options. Snapshots that depend on the machine’s default locale can drift among a workstation, CI, and user devices. Assert only the sign or zero of localeCompare(), never a particular -1 or 1.

Every step of a method chain should preserve meaningful failure information. Casually converting match()’s null to an empty array, or a missing delimiter to an empty string, may hide malformed input. If absence is valid, express that branch in the interface contract.

Finally, test whether the original input should remain unchanged. A string is immutable, but arrays and objects containing strings are still mutable. records.sort() mutates the array even though its comparator reads immutable strings, so don’t project string semantics onto an outer container.

Further reading

checkpoint

4 questions · 2 predict-the-output · 1 spot-the-bug

Copy as Markdown Interview bank Edit on GitHub Report an error Was this clear?