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 throwsEvery 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 fooledThree 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 outslice(start, end), neversubstringis it in there?includes/startsWith/endsWithwhere is it?indexOf/lastIndexOf, compare against-1fixed widthpadStart/padEnd, they never truncatesplit into characters[...str], notsplit("")swap every occurrencereplaceAllwith a plain stringhuman sort orderlocaleCompare, not<compare accented textnormalize()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.
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
toUpperCasebuilt a new string that nobody kept.sis 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", andslice(-1, 0)has its end before its start so it returns"".at(-1)counts back from the end, andslice(-1)also works.- You need to replace every
.in"a.b.c"with"/". Which is safest? 'a.b.c'.replaceAll('.', '/')—replacewith a string only swaps the first match. The regex/./gmatches every character, because an unescaped dot is a wildcard, giving"/////".replaceAllwith 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 —
lengthcounts UTF-16 code units, not characters, so anything above U+FFFF counts as 2.[...str].lengthgives 1 because string iteration walks whole code points. For user-perceived characters you needIntl.Segmenter.
Common mistakes
- Calling
trim,replaceortoUpperCaseand forgetting to keep the returned string. - Using
substringwith arguments that can arrive out of order, which silently swaps them. - Truthiness testing an
indexOfresult, where-1is truthy and0is falsy. - Treating
lengthas a character count, or splitting text withsplit("").
Takeaways
- Strings are immutable: every method returns a new one, so keep the result.
sliceis the only substring method with no surprises;at(-1)reads from the end.padStartandpadEndgrow but never truncate.replaceAllwith a plain string avoids regex escaping entirely.lengthcounts UTF-16 code units,[...str]counts code points, and neither counts what a reader calls a character.