String Methods Mastery

Mental model: A string is a frozen array of UTF-16 code units. Every method that looks like it edits one is really handing you a brand new string.

Level: intermediate · about 14 minutes

let name = 'ada';

name.toUpperCase();        // returns 'ADA' and throws it away
console.log(name);         // 'ada', untouched

name = name.toUpperCase(); // reassignment is the only way to "change" it
console.log(name);         // 'ADA'

const s = 'abc';
console.log(s[0], s.at(-1)); // 'a' 'c', reading is fine
// s[0] = 'z';               // in a module (always strict) this throws

Every string method returns a new string. None of them edit the old one.

A string is an immutable sequence of UTF-16 code units. Immutable means no method can modify it: trim, replace, toUpperCase and friends all return a new string and leave the original alone. If you called one and nothing seemed to happen, you forgot to keep the return value.

Positions: brackets, at, and negative indexes

  index      0   1   2   3
           +---+---+---+---+
           | J | a | v | a |
           +---+---+---+---+
  negative  -4  -3  -2  -1
  slice(1, 3) -> "av"      (start inclusive, end exclusive)
  slice(-2)   -> "va"      (count back from the end)
const lang = 'JavaScript';

console.log(lang.slice(0, 4));   // 'Java'
console.log(lang.slice(4));      // 'Script'
console.log(lang.slice(-6));     // 'Script', negative counts from the end
console.log(lang.slice(-6, -3)); // 'Scr'
console.log(lang.at(-1));        // 't'
console.log(lang.charAt(0));     // 'J', the pre-2021 spelling

slice: predictable

'JavaScript'.slice(4, 0);
// '' (start after end, so nothing)

'JavaScript'.slice(-6);
// 'Script'

substring: surprising

'JavaScript'.substring(4, 0);
// 'Java' (it swapped the arguments)

'JavaScript'.substring(-6);
// 'JavaScript' (negatives become 0)

Use slice everywhere. substring silently swaps out-of-order arguments, which hides bugs, and the older substr is deprecated.

Searching: ask a boolean question when you want a boolean

const path = 'assets/js/app.min.js';

console.log(path.includes('min'));       // true
console.log(path.startsWith('assets'));  // true
console.log(path.endsWith('.js'));       // true
console.log(path.indexOf('js'));         // 7, first hit
console.log(path.lastIndexOf('js'));     // 19, last hit
console.log(path.indexOf('nope'));       // -1, the "not found" sentinel

Trim, pad, repeat

console.log(JSON.stringify('  hi  '.trim()));      // "hi"
console.log(JSON.stringify('  hi  '.trimStart()));  // "hi  "
console.log('7'.padStart(2, '0'));                  // '07'
console.log('4'.padStart(3, '0') + ':' + '5'.padStart(2, '0')); // '004:05'
console.log('ab'.padEnd(5, '.'));                   // 'ab...'
console.log('-'.repeat(20));                        // a divider line

Split and join: strings to arrays and back

const csv = 'ada,grace,alan';

console.log(csv.split(','));        // ['ada', 'grace', 'alan']
console.log(csv.split(',', 2));     // ['ada', 'grace'], a limit, not a slice
console.log(csv.split(',').join(' | ')); // 'ada | grace | alan'
console.log('a-b'.split(''));       // ['a', '-', 'b']
console.log(''.split(','));         // [''], one empty string, not []
const text = 'a\u0301bc';   // an "a" plus a combining acute accent

console.log(text.length);        // 4 code units
console.log([...text].length);   // 4 code points here, they line up
const musical = '\u{1D11E}';     // one character, two code units
console.log(musical.length);            // 2, the surprise
console.log([...musical].length);       // 1, correct
console.log(musical.split('').length);  // 2, split('') was fooled

Three different answers to "how long is this string?"

Replacing

const line = 'one two one two';

console.log(line.replace('one', 'X'));     // 'X two one two', first only
console.log(line.replaceAll('one', 'X'));  // 'X two X two'
console.log(line.replace(/one/g, 'X'));    // 'X two X two', the pre-2021 way
console.log('a.b.c'.replaceAll('.', '/')); // 'a/b/c', no regex escaping needed

Case, comparison and normalisation

console.log('Straße'.toUpperCase());        // 'STRASSE', one char became two
console.log('resume'.localeCompare('résumé')); // negative, ordered before
console.log('a' < 'B');                        // false, code-unit order puts 'B' first
console.log('a'.localeCompare('B'));           // negative, human order

const composed = '\u00e9';        // one code point: e-acute
const decomposed = 'e\u0301';     // two code points: e + accent
console.log(composed === decomposed);                       // false
console.log(composed.normalize() === decomposed.normalize()); // true

Building strings

Fine for a handful of pieces

let out = '';
for (const row of rows) {
  out += row.name + '\n';
}

Clearer for a list

const out = rows
  .map((row) => row.name)
  .join('\n');

Modern engines store concatenations as a rope and flatten them lazily, so += in a loop is not the disaster it was in 2008. Choose on readability: map plus join also gets the separators right, which is where hand rolled loops usually leave a trailing comma.

Multiline and raw strings

const block = `line one
line two`;                       // real newlines, no \n needed
console.log(block.split('\n').length); // 2

console.log('C:\\Users\\ada');           // 'C:\Users\ada', escapes processed
console.log(String.raw`C:\Users\ada`);  // 'C:\Users\ada', escapes ignored
console.log(String.raw`a\nb`.length);    // 4: a, backslash, n, b
cut a piece out
slice(start, end), never substring
is it in there?
includes / startsWith / endsWith
where is it?
indexOf / lastIndexOf, compare against -1
fixed width
padStart / padEnd, they never truncate
split into characters
[...str], not split("")
swap every occurrence
replaceAll with a plain string
human sort order
localeCompare, not <
compare accented text
normalize() both sides first
const tag = ' Draft ';
tag.trim();
console.log('[' + tag + ']');

trim returns a trimmed copy and the return value was discarded, so tag still has its spaces. Strings are immutable, so no method can ever change one in place. You needed tag = tag.trim().

There is no such thing as plain text.

Joel Spolsky, on why every string carries an encoding

Try it yourself

Poke every method

const email = '  Ada.Lovelace@Example.com  ';

const clean = email.trim().toLowerCase();
console.log(JSON.stringify(clean));

const [user, host] = clean.split('@');
console.log(user, host);

const mask = user.slice(0, 2) + '*'.repeat(Math.max(0, user.length - 2));
console.log(mask + '@' + host);

console.log(clean.indexOf('@'), clean.at(-1), clean.endsWith('.com'));

Try to produce "ada.l@example.com" from the parts. Then make mask keep the last four characters instead of the first two.

Build a text table

const rows = [
  { name: 'Tea', qty: 2 },
  { name: 'Coffee', qty: 11 },
  { name: 'Oat milk', qty: 1 },
];

const width = Math.max(...rows.map((r) => r.name.length));

const text = rows
  .map((r) => r.name.padEnd(width, ' ') + '  ' + String(r.qty).padStart(3, ' '))
  .join('\n');

console.log(text);
console.log('-'.repeat(width + 5));

Add a total row. Then change the separator to a pipe and line the columns up again.

Exercises

Truncate to a hard width

Write truncate(text, max, suffix = "..."). If text is max characters or shorter, return it unchanged. Otherwise return a string that is exactly max characters long and ends with suffix. If max is smaller than the suffix, return just the first max characters of the suffix.

Align a price list

Write priceList(items) where each item is { name, price }. Return an array of lines. Each line is the name padded on the right with dots to the width of the longest name, then one space, then the price fixed to two decimals and padded on the left with spaces to width 6. Every line must come out the same length. An empty input gives an empty array.

Check yourself

What does this log?
5 'hello' — Strings are immutable, so toUpperCase built a new string that nobody kept. s is still "hello" with length 5. Any string method call whose result you ignore is a no-op.
Which expression reliably returns the last character of str?
str.at(-1) — str[str.length] is one past the end (undefined), str[-1] looks for a property named "-1", and slice(-1, 0) has its end before its start so it returns "". at(-1) counts back from the end, and slice(-1) also works.
You need to replace every . in "a.b.c" with "/". Which is safest?
'a.b.c'.replaceAll('.', '/') — replace with a string only swaps the first match. The regex /./g matches every character, because an unescaped dot is a wildcard, giving "/////". replaceAll with a plain string treats the dot literally, which is exactly what you asked for.
What is "\u{1F600}".length, given that the code point needs two UTF-16 units?
2 — length counts UTF-16 code units, not characters, so anything above U+FFFF counts as 2. [...str].length gives 1 because string iteration walks whole code points. For user-perceived characters you need Intl.Segmenter.

Common mistakes

  • Calling trim, replace or toUpperCase and forgetting to keep the returned string.
  • Using substring with arguments that can arrive out of order, which silently swaps them.
  • Truthiness testing an indexOf result, where -1 is truthy and 0 is falsy.
  • Treating length as a character count, or splitting text with split("").

Takeaways

  • Strings are immutable: every method returns a new one, so keep the result.
  • slice is the only substring method with no surprises; at(-1) reads from the end.
  • padStart and padEnd grow but never truncate.
  • replaceAll with a plain string avoids regex escaping entirely.
  • length counts UTF-16 code units, [...str] counts code points, and neither counts what a reader calls a character.