GUIDE · JavaScript

Strings

lesson10 sectionsruns in browser60 asserts111 lines

At a glance

INDEX
s[i], s.at(-1), s.charAt(i), s.charCodeAt(i), String.fromCharCode(n)
SLICE
s.slice(start, end) — negatives OK. Prefer over substring/substr.
SPLIT
s.split(sep) keeps empties; [...s] for chars; s.split(/\s+/) regex
TRAP
join() default separator is ","; replace() replaces FIRST only; '.' in a regex is a wildcard; < > compare by char code ('B' < 'a')
SAY
immutablecode unit vs code pointlexicographiccase-fold

The idea#

Think of a string like a row of letter tiles glued to a stick. You can read tile 0. You can count the tiles with length. You cannot pry one tile off and glue a different letter down. text[0] = 'h' leaves the stick exactly as it was. Any change is a new stick: 'h' + text.slice(1).

That is what immutable means. slice, toLowerCase, trim, replace, padStart, and repeat each return a new string. The old stick stays put, so a helper cannot rewrite the text you passed in. Tiles are indexed from 0. s.at(-1) reads from the right end. slice(start, end) copies a window. The end index is excluded, and a negative index counts backward from the last tile. slice is the cutter to reach for when the indexes are negative.

Each tile also has a numeric code. 'a' is 97, 'A' is 65, '0' is 48. charCodeAt reads the code of one tile. String.fromCharCode presses a code back into a tile. A Caesar shift is arithmetic on that code, wrapped around 26 so it stays a lowercase letter. Comparisons with < and > use those codes too. That order is lexicographic at the code-unit level. 'B' < 'a' is true, because 66 comes before 97.

Why it matters#

Names, dates, and "does this sentence read the same backward" are all sticks of tiles.

replace changes the first match and stops. 'v1.2.3'.replace('.', '-') is 'v1-2.3'. replaceAll, or a regex with the g flag, walks the whole stick. In a regex, a bare . is a wildcard, any character, so the pattern escapes it: /\./g. join() with no argument inserts commas. ['v1', 'v2'].join() is 'v1,v2'. Pass '' when the tiles should sit tight against each other. split(',') keeps empty pieces, so 'a,b,,c' has four parts, one of them ''.

localeCompare asks the locale for dictionary order. toLowerCase is the ASCII case change. Full case-folding is a bigger job: the run checks that 'Straße'.toLowerCase() and 'strasse' are different strings. Say case-fold for the language's idea of the same letter.

The stick is stored as UTF-16 code units. One emoji can occupy two units, so '😀'.length is 2. Spread walks code points: [...'😀'].length is 1. split('') walks units and can tear a character in half. A combining accent can make 'é' look like one letter and measure as two until you normalize. NFC packs the mark back into a single character. NFD pulls it apart. Compare text after you normalize it, when the problem mentions accents or emoji.

Building with += in a loop is fine in this engine, and so is an array of pieces plus join. Template literals interpolate and can span lines. Once either side of + is a string, the result is a string, so `${1}${2}` is '12'. toFixed returns a string of digits.

A small example#

JS
const sign = 'Cafe'; function retitle(label, first) {  return first + label.slice(1);} const next = retitle(sign, 'c');const dots = 'v1.2.3';const firstOnly = dots.replace('.', '-');const allOfThem = dots.replace(/\./g, '-');const csv = ['v1', 'v2'].join();

sign is still 'Cafe'. next is 'cafe', a new stick, because the glued tiles would not move. firstOnly is 'v1-2.3'. replace stops after the first dot. allOfThem is 'v1-2-3', because the pattern escapes the dot and carries the g flag. csv is 'v1,v2'. join with no separator uses a comma.

What the code below is doing#

The code below is ten sections on one idea: every operation returns a new string.

Basics cover length, index access, at(-1), slice with a negative window, indexOf, lastIndexOf, includes, startsWith, endsWith, case change, trim, repeat, padStart, padEnd, and concat. Split and join show empty pieces kept, a whitespace regex, a limit argument, spread versus split(''), the default comma, and a reverse-by-words one-liner. Build and reverse do spread-then-reverse-then-join, and a loop that prepends each character. A comment notes that += in a loop is fine here.

Char codes read 'a', 'A', and '0', build a letter with fromCharCode, run a Caesar shift, and test digit, letter, and alphanumeric with ranges and a small regex. A 26-slot array counts lowercase frequencies. Replace shows first-only, replaceAll, an escaped dot, a function replacer that capitalizes words, and $1 style group refs that flip year-month-day into day/month/year.

Compare checks 'B' < 'a', localeCompare, ordinary ===, and the Straße case-fold example. Template literals do a plural and a multi-line string. Number to string and back covers String, toString(2), Number, parseInt with a radix, toFixed as a string, and concatenation of '1' and '2'.

Classic patterns: a palindrome that lowercases and strips non-alphanumerics, an anagram check by sorting code points, a vowel count that treats a null from match as no vowels, capitalize, a whitespace split, and a run-length compress. Unicode notes close the run: emoji length 2 versus one code point, and normalize('NFD') on 'é'.

The header's trap, in one breath: strings are immutable. join() defaults to a comma. replace changes the first match only. A bare . in a regex is a wildcard. < and > compare code units, which is why 'B' < 'a'. Say immutable, code unit versus code point, lexicographic, and case-fold.

Everything below is the demonstration. Same idea, written as code you can run.

JSjavascript/strings.js · setup
'use strict';const assert = require('node:assert/strict');
01

Basics#

JS
const s = 'Hello, World';
Verified by 16 asserts
JS
assert.equal(s.length, 12);assert.equal(s[0], 'H');assert.equal(s.at(-1), 'd');assert.equal(s.slice(7), 'World');assert.equal(s.slice(-5, -1), 'Worl');assert.equal(s.indexOf('o'), 4);assert.equal(s.lastIndexOf('o'), 8);assert.equal(s.indexOf('zzz'), -1);assert.equal(s.includes('World'), true);assert.equal(s.startsWith('He') && s.endsWith('ld'), true);assert.equal(s.toLowerCase(), 'hello, world');assert.equal('  x '.trim(), 'x');                           // trimStart / trimEnd tooassert.equal('ab'.repeat(3), 'ababab');assert.equal('7'.padStart(3, '0'), '007');assert.equal('7'.padEnd(3, '.'), '7..');assert.equal('a-b'.concat('-c'), 'a-b-c');                   // just use + or template literal
02

Split / join / chars#

Verified by 8 asserts
JS
assert.deepEqual('a,b,,c'.split(','), ['a', 'b', '', 'c']);  // keeps emptiesassert.deepEqual('a  b   c'.split(/\s+/), ['a', 'b', 'c']);assert.deepEqual('a,b,c'.split(',', 2), ['a', 'b']);         // limitassert.deepEqual([...'abc'], ['a', 'b', 'c']);               // unicode-safe charsassert.deepEqual('abc'.split(''), ['a', 'b', 'c']);          // NOT unicode-safe (splits surrogate pairs)assert.equal(['a', 'b'].join(), 'a,b');                      // default ","assert.equal(['a', 'b'].join(''), 'ab');assert.equal('a b c'.split(' ').reverse().join(' '), 'c b a');
03

Build / reverse#

JS
let out = '';for (const ch of 'abc') out = ch + out;// Building in a loop with += is fine in V8 (ropes). Array+join is also fine.
Verified by 2 asserts
JS
assert.equal([...'abc'].reverse().join(''), 'cba');assert.equal(out, 'cba');
04

Char codes#

letter math

JS
const shift = (ch, k) => String.fromCharCode(((ch.charCodeAt(0) - 97 + k) % 26 + 26) % 26 + 97);   // Caesarconst isDigit = ch => ch >= '0' && ch <= '9';const isLetter = ch => /^[a-z]$/i.test(ch);const isAlnum = ch => /^[a-z0-9]$/i.test(ch);const freq26 = str => { const f = new Array(26).fill(0); for (const c of str) f[c.charCodeAt(0) - 97]++; return f; };
Verified by 7 asserts
JS
assert.equal('a'.charCodeAt(0), 97);assert.equal('A'.charCodeAt(0), 65);assert.equal('0'.charCodeAt(0), 48);assert.equal(String.fromCharCode(97 + 2), 'c');assert.equal(shift('y', 3), 'b');assert.equal(isDigit('5') && isLetter('q') && !isAlnum('!'), true);assert.equal(freq26('abca')[0], 2);
05

Replace#

Verified by 5 asserts
JS
assert.equal('a.b.c'.replace('.', '-'), 'a-b.c');           // first onlyassert.equal('a.b.c'.replaceAll('.', '-'), 'a-b-c');         // Node 15+assert.equal('a.b.c'.replace(/\./g, '-'), 'a-b-c');          // escape the dotassert.equal('john smith'.replace(/\b\w/g, c => c.toUpperCase()), 'John Smith');   // function replacerassert.equal('2024-01-31'.replace(/(\d+)-(\d+)-(\d+)/, '$3/$2/$1'), '31/01/2024'); // group refs
06

Compare#

Verified by 4 asserts
JS
assert.equal('B' < 'a', true);                               // char codeassert.equal('a'.localeCompare('B') < 0, true);              // locale-awareassert.equal('abc' === 'abc', true);assert.equal('Straße'.toLowerCase() === 'strasse', false);   // case-folding is not toLowerCase; fine for ASCII
07

Template literals#

JS
const n = 3, name = 'x';
Verified by 2 asserts
JS
assert.equal(`${name}: ${n} item${n === 1 ? '' : 's'}`, 'x: 3 items');assert.equal(`multiline`.split('\n').length, 2);
08

Number <-> string#

Verified by 6 asserts
JS
assert.equal(String(42), '42');assert.equal((42).toString(2), '101010');assert.equal(Number('42'), 42);assert.equal(parseInt('101', 2), 5);assert.equal((3.14159).toFixed(2), '3.14');                  // STRINGassert.equal(`${1}${2}`, '12');                              // concatenation, not addition
09

Classic patterns#

JS
const isPalindrome = str => { const t = str.toLowerCase().replace(/[^a-z0-9]/g, ''); return t === [...t].reverse().join(''); };const isAnagram = (a, b) => a.length === b.length && [...a].sort().join('') === [...b].sort().join('');const countVowels = str => (str.match(/[aeiou]/gi) ?? []).length;   // match returns null on no matchconst capitalize = str => str.charAt(0).toUpperCase() + str.slice(1);const words = 'the quick  brown'.trim().split(/\s+/);const compress = str => str.replace(/(.)\1*/g, (run, ch) => ch + (run.length > 1 ? run.length : ''));
Verified by 6 asserts
JS
assert.equal(isPalindrome('A man, a plan, a canal: Panama'), true);assert.equal(isAnagram('listen', 'silent'), true);assert.equal(countVowels('rhythm'), 0);assert.equal(capitalize('abc'), 'Abc');assert.deepEqual(words, ['the', 'quick', 'brown']);assert.equal(compress('aaabccdddd'), 'a3bc2d4');
10

Unicode notes (only if the problem mentions emoji/unicode)#

Verified by 3 asserts
JS
assert.equal('😀'.length, 2);                                // UTF-16 code unitsassert.equal([...'😀'].length, 1);                           // code pointsassert.equal('é'.normalize('NFD').length, 2);                // combining marks; normalize('NFC') to compare

See also